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

@@ -1,14 +1,26 @@
/**
* src/config.ts — T4: environment config loader
* src/config.ts — T4 / T-config: environment config loader
*
* Reads env vars once at startup, validates them, derives allowedOrigins from
* local NIC IPs (M1 — NOT from bindHost; 0.0.0.0 is never a valid Origin),
* and returns a frozen Config object.
*
* v0.7 adds 21 new env vars for the Walk-away Workbench features (A1A5, B1B4).
* The returned object is a superset of Config (all existing fields + 21 new v0.7
* fields). Callers that use `const cfg = loadConfig(env)` (no explicit `: Config`
* annotation) get the full type with all new fields accessible. Callers that
* narrow to `Config` (e.g. function params typed as Config) still work because
* the wider type is structurally assignable to Config.
*
* NOTE(T-config): The Config interface in src/types.ts should be extended with the
* 21 new fields listed below so downstream tasks (T-server-wire, N-push, etc.) can
* access them type-safely via the Config type. This is a coordination point for the
* orchestrator — the impl is here, the interface declaration belongs in T-types.
*/
import os from 'node:os'
import path from 'node:path'
import type { Config, EnvLike } from './types.js'
import type { Config, EnvLike, PermissionMode } from './types.js'
import { tmuxAvailable } from './session/tmux.js'
/** USE_TMUX: '1'/'true'/'on' → on, '0'/'false'/'off' → off, else auto-detect tmux. */
@@ -36,6 +48,25 @@ const DEFAULT_PROJECT_SCAN_DEPTH = 4 // v0.6: how deep to scan roots for .git
const DEFAULT_PROJECT_SCAN_TTL_MS = 10_000 // v0.6: /projects discovery cache TTL
const DEFAULT_EDITOR_CMD = 'code' // v0.6: POST /open-in-editor launches this on the host
// v0.7 Walk-away Workbench defaults
// A1 push notifications
const DEFAULT_PUSH_MAX_SUBS = 50
// A4 timeline
const DEFAULT_TIMELINE_MAX = 200
// A5 stuck detection
const DEFAULT_STUCK_TTL_SEC = 600 // 10 minutes (env var in seconds)
// B1 git diff
const DEFAULT_DIFF_TIMEOUT_MS = 2_000
const DEFAULT_DIFF_MAX_BYTES = 2 * 1024 * 1024 // 2 MB
const DEFAULT_DIFF_MAX_FILES = 300
// B2 statusline telemetry
const DEFAULT_STATUSLINE_TTL_MS = 30_000
// B3 worktrees
const DEFAULT_WORKTREE_TIMEOUT_MS = 10_000
/** Valid --permission-mode values (R0-confirmed). Whitelist for validation. */
const PERMISSION_MODES: readonly PermissionMode[] = ['default', 'acceptEdits', 'plan', 'auto']
// ── helpers ───────────────────────────────────────────────────────────────────
/** Parse a non-negative integer env value (0 allowed), or the fallback when unset. */
@@ -63,6 +94,31 @@ function parseBool(raw: string | undefined, fallback: boolean): boolean {
return fallback
}
/** Parse a seconds-valued env var and return the equivalent milliseconds, or fallback (ms). */
function parseSecondsToMs(
raw: string | undefined,
label: string,
fallbackMs: number,
): number {
if (raw === undefined) return fallbackMs
const n = Number(raw)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
throw new Error(
`Invalid config: ${label}=${JSON.stringify(raw)} — must be a non-negative integer (seconds)`,
)
}
return n * 1000
}
/** Parse and validate a PermissionMode env var; throws for unknown values (including empty string). */
function parsePermissionMode(raw: string | undefined, label: string): PermissionMode {
if (raw === undefined) return 'default'
if ((PERMISSION_MODES as readonly string[]).includes(raw)) return raw as PermissionMode
throw new Error(
`Invalid config: ${label}=${JSON.stringify(raw)} — must be one of ${PERMISSION_MODES.join('|')}`,
)
}
/** Parse PROJECT_ROOTS (comma-separated absolute paths); expand a leading '~' to homeDir.
* Empty / unset → [homeDir]. */
function parseProjectRoots(raw: string | undefined, homeDir: string): readonly string[] {
@@ -176,7 +232,15 @@ function deriveAllowedOrigins(port: number, extraEnv: string | undefined): reado
* Invalid values (port out of range, TTL non-integer, etc.) throw immediately
* so the server never starts in a misconfigured state (fail-fast).
*
* The returned Config is Object.freeze'd — immutable after construction.
* The returned object is Object.freeze'd — immutable after construction. It
* contains all existing Config fields plus 21 new v0.7 Walk-away Workbench
* fields. The return type is inferred (wider than Config) so callers can access
* the new fields without explicit type assertions. Functions accepting Config
* still work because the wider type is structurally assignable to Config.
*
* L5 (SEC-M4): permTimeoutMs must be > 0 — it is used to compute the
* --max-time flag in setup-hooks.mjs (T-hooks-installer). Zero would make
* --max-time=0 which disables the curl timeout entirely.
*/
export function loadConfig(env: EnvLike): Config {
const port = parsePort(env['PORT'])
@@ -218,6 +282,13 @@ export function loadConfig(env: EnvLike): Config {
DEFAULT_PERM_TIMEOUT_MS,
)
// L5 / SEC-M4: permTimeoutMs must be > 0 (T-hooks-installer uses it for --max-time)
if (permTimeoutMs === 0) {
throw new Error(
'Invalid config: PERM_TIMEOUT_MS must be > 0 (used to calculate --max-time for setup-hooks)',
)
}
const reapIntervalMs = parseNonNegativeInt(
env['REAP_INTERVAL_MS'],
'REAP_INTERVAL_MS',
@@ -245,7 +316,76 @@ export function loadConfig(env: EnvLike): Config {
const projectDirtyCheck = parseBool(env['PROJECT_DIRTY_CHECK'], true)
const editorCmd = env['EDITOR_CMD']?.trim() || DEFAULT_EDITOR_CMD
return Object.freeze({
// ── v0.7 Walk-away Workbench — 21 new env vars ─────────────────────────────
// A1 push notifications (SEC-C5: vapidPrivateKey never logged by this module)
const vapidPublicKey = env['VAPID_PUBLIC_KEY'] || undefined
const vapidPrivateKey = env['VAPID_PRIVATE_KEY'] || undefined // SECRET — never log
const vapidSubject = env['VAPID_SUBJECT'] || undefined
const pushStorePath =
env['PUSH_STORE_PATH'] || path.join(homeDir, '.web-terminal-push-subs.json')
const pushMaxSubs = parseNonNegativeInt(env['PUSH_MAX_SUBS'], 'PUSH_MAX_SUBS', DEFAULT_PUSH_MAX_SUBS)
const notifyDone = parseBool(env['NOTIFY_DONE'], true) // default on
const notifyDnd = parseBool(env['NOTIFY_DND'], false) // default off (do not disturb)
// DECISION_TOKEN_TTL_MS defaults to permTimeoutMs (parsed after it, order matters)
const decisionTokenTtlMs = parseNonNegativeInt(
env['DECISION_TOKEN_TTL_MS'],
'DECISION_TOKEN_TTL_MS',
permTimeoutMs,
)
// A4 activity timeline
const timelineMax = parseNonNegativeInt(env['TIMELINE_MAX'], 'TIMELINE_MAX', DEFAULT_TIMELINE_MAX)
const timelineEnabled = parseBool(env['TIMELINE_ENABLED'], true)
// A5 stuck detection (STUCK_TTL env var is in seconds; stored in ms)
const stuckTtlMs = parseSecondsToMs(env['STUCK_TTL'], 'STUCK_TTL', DEFAULT_STUCK_TTL_SEC * 1000)
const stuckAlert = parseBool(env['STUCK_ALERT'], true)
// B1 git diff viewer
const diffTimeoutMs = parseNonNegativeInt(
env['DIFF_TIMEOUT_MS'],
'DIFF_TIMEOUT_MS',
DEFAULT_DIFF_TIMEOUT_MS,
)
const diffMaxBytes = parseNonNegativeInt(
env['DIFF_MAX_BYTES'],
'DIFF_MAX_BYTES',
DEFAULT_DIFF_MAX_BYTES,
)
const diffMaxFiles = parseNonNegativeInt(
env['DIFF_MAX_FILES'],
'DIFF_MAX_FILES',
DEFAULT_DIFF_MAX_FILES,
)
// B2 statusLine telemetry
const statuslineTtlMs = parseNonNegativeInt(
env['STATUSLINE_TTL_MS'],
'STATUSLINE_TTL_MS',
DEFAULT_STATUSLINE_TTL_MS,
)
// B3 git worktree creation
const worktreeEnabled = parseBool(env['WORKTREE_ENABLED'], true)
const worktreeRoot = env['WORKTREE_ROOT'] || undefined // undefined → computed at creation time
const worktreeTimeoutMs = parseNonNegativeInt(
env['WORKTREE_TIMEOUT_MS'],
'WORKTREE_TIMEOUT_MS',
DEFAULT_WORKTREE_TIMEOUT_MS,
)
// B4 permission mode relay
const defaultPermissionMode = parsePermissionMode(
env['DEFAULT_PERMISSION_MODE'],
'DEFAULT_PERMISSION_MODE',
)
const allowAutoMode = parseBool(env['ALLOW_AUTO_MODE'], false) // default off (SEC-M5)
// ── assemble + freeze ───────────────────────────────────────────────────────
// `satisfies Config` on the base object verifies all existing Config fields
// are present without triggering excess-property checks on the v0.7 additions.
const base = {
port,
bindHost,
shellPath,
@@ -266,5 +406,31 @@ export function loadConfig(env: EnvLike): Config {
projectScanTtlMs,
projectDirtyCheck,
editorCmd,
} satisfies Config)
}
return Object.freeze({
...base,
// v0.7 additions (21 new fields)
vapidPublicKey,
vapidPrivateKey,
vapidSubject,
pushStorePath,
pushMaxSubs,
notifyDone,
notifyDnd,
decisionTokenTtlMs,
timelineMax,
timelineEnabled,
stuckTtlMs,
stuckAlert,
diffTimeoutMs,
diffMaxBytes,
diffMaxFiles,
statuslineTtlMs,
worktreeEnabled,
worktreeRoot,
worktreeTimeoutMs,
defaultPermissionMode,
allowAutoMode,
})
}

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

174
src/push/push-service.ts Normal file
View File

@@ -0,0 +1,174 @@
/**
* src/push/push-service.ts (N-push, A1) — VAPID-signed Web Push sender that
* implements `NotifyService`.
*
* The manager and server call `notify(session, cls, token?)` on the three
* proactive transitions (needs-input / done / stuck). This module signs a
* minimal `PushPayload` (§3.3) with VAPID and fans it out to every stored
* subscription, pruning ones the push service reports gone (404/410).
*
* Byte-shuttle boundary (SP1): the payload carries ONLY a session id, a short
* cwd-derived label, the notify class, and (needs-input only) the per-decision
* capability token — never raw terminal output and never a secret (SEC-C5).
*
* web-push is injected behind the `PushSender` seam so the signing/transport is
* fully unit-testable without real VAPID keys or network; the default sender
* wraps the `web-push` package.
*/
import webpush from 'web-push';
import { basename } from 'node:path';
import type {
Config,
NotifyClass,
NotifyService,
PushPayload,
PushSubscriptionRecord,
Session,
} from '../types.js';
import type { SubscriptionStore } from './subscription-store.js';
/** Fallback VAPID `sub` when VAPID_SUBJECT is unset (web-push requires one). */
const DEFAULT_VAPID_SUBJECT = 'mailto:admin@localhost';
/** TTL (seconds) for low-priority DONE/STUCK pushes — they need not linger long. */
const DEFAULT_TTL_SECONDS = 600;
/** Push service "subscription gone" status codes → prune the subscription. */
const GONE_STATUS = new Set([404, 410]);
/** web-push send options we set (subset of web-push's RequestOptions). */
export interface PushSendOptions {
TTL?: number;
urgency?: 'very-low' | 'low' | 'normal' | 'high';
topic?: string;
}
/** Transport seam over `web-push` (injectable for tests). */
export interface PushSender {
setVapid(subject: string, publicKey: string, privateKey: string): void;
/** Resolves on accept; rejects with a `{ statusCode }`-bearing error on failure. */
send(record: PushSubscriptionRecord, payload: string, options: PushSendOptions): Promise<void>;
}
export interface PushServiceDeps {
sender?: PushSender;
}
/** Default sender backed by the `web-push` package. */
function createWebPushSender(): PushSender {
return {
setVapid(subject, publicKey, privateKey) {
webpush.setVapidDetails(subject, publicKey, privateKey);
},
async send(record, payload, options) {
await webpush.sendNotification(
{ endpoint: record.endpoint, keys: record.keys },
payload,
options,
);
},
};
}
/** Safely read a `statusCode` off an unknown thrown value (web-push WebPushError). */
function statusCodeOf(err: unknown): number | undefined {
if (err !== null && typeof err === 'object' && 'statusCode' in err) {
const code = (err as { statusCode: unknown }).statusCode;
if (typeof code === 'number') return code;
}
return undefined;
}
/** A valid Web Push `Topic` header is URL-safe base64, ≤32 chars. A UUID minus
* its dashes is exactly 32 chars of [0-9a-f] — collapses same-session pushes
* (same topic ⇒ the push service replaces, not stacks; A1-FR7). */
function collapseTopic(sessionId: string): string {
return sessionId.replace(/-/g, '').replace(/[^A-Za-z0-9_]/g, '').slice(0, 32);
}
function buildPayload(session: Session, cls: NotifyClass, token?: string): PushPayload {
const label = session.cwd ? basename(session.cwd) : undefined;
return {
sessionId: session.meta.id,
cls,
...(label ? { detail: label } : {}),
...(cls === 'needs-input' && token ? { token } : {}),
};
}
function buildSendOptions(session: Session, cls: NotifyClass, cfg: Config): PushSendOptions {
return {
urgency: cls === 'done' ? 'low' : 'high',
topic: collapseTopic(session.meta.id),
TTL: cls === 'needs-input' ? Math.ceil(cfg.decisionTokenTtlMs / 1000) : DEFAULT_TTL_SECONDS,
};
}
/**
* Create the push notification service.
* @param cfg frozen runtime config (VAPID keys, DND/done toggles)
* @param store subscription store (read for fan-out, pruned on 404/410)
* @param deps optional injected sender (defaults to the real web-push sender)
*/
export function createPushService(
cfg: Config,
store: SubscriptionStore,
deps: PushServiceDeps = {},
): NotifyService {
const sender = deps.sender ?? createWebPushSender();
const enabled = Boolean(cfg.vapidPublicKey && cfg.vapidPrivateKey);
if (enabled) {
sender.setVapid(
cfg.vapidSubject ?? DEFAULT_VAPID_SUBJECT,
cfg.vapidPublicKey as string,
cfg.vapidPrivateKey as string,
);
}
function shouldSend(cls: NotifyClass): boolean {
if (!enabled) return false;
if (cfg.notifyDnd) return false;
if (cls === 'done' && !cfg.notifyDone) return false;
return true;
}
async function sendOne(
record: PushSubscriptionRecord,
payload: string,
options: PushSendOptions,
dead: string[],
): Promise<void> {
try {
await sender.send(record, payload, options);
} catch (err) {
const code = statusCodeOf(err);
if (code !== undefined && GONE_STATUS.has(code)) {
dead.push(record.endpoint);
} else {
// Transient failure (network/5xx): keep the subscription, log without secrets.
console.error(`push-service: send failed (status ${code ?? 'unknown'})`);
}
}
}
return {
isEnabled: () => enabled,
async notify(session: Session, cls: NotifyClass, token?: string): Promise<void> {
if (!shouldSend(cls)) return;
const records = store.list();
if (records.length === 0) return;
const payload = JSON.stringify(buildPayload(session, cls, token));
const options = buildSendOptions(session, cls, cfg);
const dead: string[] = [];
await Promise.all(records.map((record) => sendOne(record, payload, options, dead)));
if (dead.length > 0) {
store.prune(dead);
await store.persist();
}
},
};
}

View File

@@ -0,0 +1,112 @@
/**
* src/push/subscription-store.ts (N-push, A1) — persistent store of browser
* Web Push subscriptions.
*
* Holds the `PushSubscriptionRecord`s the push-service signs and sends to. The
* file is SENSITIVE (endpoint + keys) — persisted with 0600 permissions and
* NEVER exposed via any GET route (SEC-M2). A `PUSH_MAX_SUBS` FIFO cap bounds
* memory/disk against a flood of subscribe calls (SEC-M1).
*
* All mutations are immutable: the internal array is REPLACED with a new array,
* never mutated in place; `list()` returns a frozen shallow copy.
*
* Loading is best-effort (A1): a missing file → empty; malformed JSON → empty
* (logged); invalid entries are filtered out. The push-service stays usable.
*/
import { readFileSync } from 'node:fs';
import { chmod, writeFile } from 'node:fs/promises';
import type { PushSubscriptionRecord } from '../types.js';
/** 0600 — owner read/write only; the file holds subscription secrets (SEC-M2). */
const FILE_MODE = 0o600;
export interface SubscriptionStore {
/** Frozen snapshot of current subscriptions (immutable to callers). */
list(): readonly PushSubscriptionRecord[];
/** Add a record (dedup by endpoint, FIFO-capped at maxSubs). Throws on invalid input. */
add(record: PushSubscriptionRecord): void;
/** Remove by endpoint; no-op when absent. */
remove(endpoint: string): void;
/** Remove a batch of dead endpoints (404/410 from the push service). */
prune(deadEndpoints: readonly string[]): void;
/** Persist to disk (0600). Best-effort: logs and resolves on failure. */
persist(): Promise<void>;
}
/** Type guard: a structurally valid, persistable subscription record. */
export function isValidSubscriptionRecord(x: unknown): x is PushSubscriptionRecord {
if (x === null || typeof x !== 'object') return false;
const r = x as Record<string, unknown>;
if (typeof r['endpoint'] !== 'string' || r['endpoint'].length === 0) return false;
const keys = r['keys'];
if (keys === null || typeof keys !== 'object') return false;
const k = keys as Record<string, unknown>;
if (typeof k['p256dh'] !== 'string' || typeof k['auth'] !== 'string') return false;
if (typeof r['createdAt'] !== 'number' || !Number.isFinite(r['createdAt'])) return false;
return true;
}
function loadRecords(filePath: string): PushSubscriptionRecord[] {
let raw: string;
try {
raw = readFileSync(filePath, 'utf8');
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') {
console.error('subscription-store: load failed, starting empty', err);
}
return [];
}
try {
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(isValidSubscriptionRecord);
} catch (err) {
console.error('subscription-store: malformed JSON, starting empty', err);
return [];
}
}
/**
* Load (or initialise empty) a subscription store backed by `filePath`.
* @param filePath absolute path to the JSON store (cfg.pushStorePath)
* @param maxSubs FIFO cap on stored subscriptions (cfg.pushMaxSubs)
*/
export function loadSubscriptionStore(filePath: string, maxSubs: number): SubscriptionStore {
let records: PushSubscriptionRecord[] = loadRecords(filePath);
return {
list(): readonly PushSubscriptionRecord[] {
return Object.freeze(records.slice());
},
add(record: PushSubscriptionRecord): void {
if (!isValidSubscriptionRecord(record)) {
throw new TypeError('subscription-store: invalid subscription record');
}
const deduped = records.filter((r) => r.endpoint !== record.endpoint);
const next = [...deduped, record];
records = next.length > maxSubs ? next.slice(next.length - maxSubs) : next;
},
remove(endpoint: string): void {
records = records.filter((r) => r.endpoint !== endpoint);
},
prune(deadEndpoints: readonly string[]): void {
if (deadEndpoints.length === 0) return;
const dead = new Set(deadEndpoints);
records = records.filter((r) => !dead.has(r.endpoint));
},
async persist(): Promise<void> {
try {
await writeFile(filePath, JSON.stringify(records, null, 2), { mode: FILE_MODE });
// writeFile's mode only applies on creation; enforce 0600 if the file pre-existed.
await chmod(filePath, FILE_MODE);
} catch (err) {
console.error('subscription-store: persist failed', err);
}
},
};
}

View File

@@ -1,23 +1,18 @@
/**
* src/server.ts (T14) — HTTP + WebSocket wiring layer.
* src/server.ts (T14 + T-server-wire) — HTTP + WebSocket wiring layer.
*
* Responsibilities (ARCHITECTURE §3.6):
* 1. Express static hosting of public/ (including public/build/ esbuild output).
* 2. WebSocketServer in noServer mode (L3) — never auto-attaches its own upgrade.
* 3. HTTP 'upgrade' event (single entry point):
* - Wrong path → socket.destroy()
* - Origin not allowed → 401 + destroy()
* - Passes → wss.handleUpgrade → wss.emit('connection')
* 4. WS 'connection': wait for first 'attach' frame → handleAttach → send 'attached'
* catch (spawn failure, M4) → send exit(-1) + close (only this connection).
* 5. WS 'message': parse → ok:false discard+log; route input/resize.
* 6. WS 'close' → manager.detachWs (never kills PTY, invariant #2).
* 7. SIGINT/SIGTERM → manager.shutdown() + http server close.
* Responsibilities (ARCHITECTURE §3.6): static hosting; WS upgrade gate (wrong
* path → destroy; bad Origin → 401, L3); WS connection (first frame must be
* 'attach', M4 spawn-failure → exit(-1)+close); message routing; close → detach
* (never kills PTY, invariant #2); SIGINT/SIGTERM → shutdown. M5: every ws.send
* guarded by readyState === WS_OPEN. L5: maxPayload cap. wsPath from config (#8).
*
* M5: every ws.send guarded by readyState === WS_OPEN.
* L3: noServer:true — no double upgrade handling.
* L5: maxPayload: cfg.maxPayloadBytes in WebSocketServer options.
* No hardcoded strings: wsPath comes from config (invariant 8).
* v0.7 (T-server-wire) adds out-of-band side-channel routes — the terminal
* stream stays a byte-shuttle. A1 push (/push/*, /hook/decision + C1 hold gate),
* A4 timeline (/…/events), A5 reaper sweepStuck, B1 diff (/projects/diff), B2
* statusLine (/hook/status), B3 worktree (/projects/worktree), B4 approve mode,
* GET /config/ui. State-changing routes carry requireAllowedOrigin (CSRF) and
* per-IP rate limits; loopback-only ingest keeps isLoopback.
*
* Style: immutable data, explicit error handling, no silent swallowing.
*/
@@ -27,6 +22,8 @@ import net from 'node:net'
import { URL } from 'node:url'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { randomUUID } from 'node:crypto'
import { stat } from 'node:fs/promises'
import express from 'express'
import type { Response } from 'express'
@@ -41,9 +38,20 @@ import { parseHookEvent } from './http/hook.js'
import { listSessions } from './http/history.js'
import { buildProjects, buildProjectDetail } from './http/projects.js'
import { openInEditor } from './http/editor.js'
import { getDiff } from './http/diff.js'
import { parseStatusLine } from './http/statusline.js'
import { createWorktree } from './http/worktrees.js'
import { createSessionManager } from './session/manager.js'
import { detachWs, writeInput, setClientDims } from './session/session.js'
import type { Config } from './types.js'
import { loadSubscriptionStore } from './push/subscription-store.js'
import { createPushService } from './push/push-service.js'
import type {
Config,
PermissionGate,
PermissionMode,
PushSubscriptionRecord,
UiConfig,
} from './types.js'
import { WS_OPEN } from './types.js'
// ── defaults ──────────────────────────────────────────────────────────────────
@@ -57,9 +65,78 @@ const RATE_WINDOW_MS = 1000
// Max chars of a user-controlled string written to a log line (M2 log injection).
const LOG_FIELD_MAX = 200
/** The decision JSON a PermissionRequest command hook writes to stdout. */
function permDecision(behavior: 'allow' | 'deny'): unknown {
return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: { behavior } } }
// Per-IP sliding-window rate limits for the new state-changing routes (SEC-H9,
// review #9). Fixed security policy — not user config.
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
/** The four permission modes the WS approve relay accepts (B4); else undefined. */
const PERMISSION_MODES: ReadonlySet<string> = new Set<PermissionMode>([
'default',
'acceptEdits',
'plan',
'auto',
])
/** Decision JSON a PermissionRequest hook writes to stdout (H3). `mode` (B4)
* carries the updated permission mode back to Claude on a plan-gate approval. */
function permDecision(behavior: 'allow' | 'deny', mode?: PermissionMode): unknown {
const decision: Record<string, unknown> = { behavior }
if (mode !== undefined) decision['mode'] = mode
return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision } }
}
/** Whitelist-validate an optional `approve.mode` from a raw WS frame (B4). The
* shared browser-safe protocol parser cannot surface it (must stay DOM-free),
* so the WS wiring layer validates it here against PermissionMode. Never throws. */
function parseApproveMode(raw: string): PermissionMode | undefined {
try {
const obj = JSON.parse(raw) as Record<string, unknown>
const m = obj?.['mode']
return typeof m === 'string' && PERMISSION_MODES.has(m) ? (m as PermissionMode) : undefined
} catch {
return undefined
}
}
/** Per-IP sliding-window limiter (in-memory). true (and records) when under the
* cap for the window, false when over. Immutable per-IP arrays (filter → new). */
function createRateLimiter(maxPerWindow: number, windowMs: number): (ip: string, now: number) => boolean {
const hits = new Map<string, number[]>()
return (ip, now): boolean => {
const recent = (hits.get(ip) ?? []).filter((t) => now - t < windowMs)
if (recent.length >= maxPerWindow) {
hits.set(ip, recent)
return false
}
hits.set(ip, [...recent, now])
return true
}
}
/** Three-prong read-only diff path check (SEC-H7): absolute + dir + has .git. */
async function isValidGitDir(target: string): Promise<boolean> {
if (typeof target !== 'string' || !path.isAbsolute(target)) return false
try {
const s = await stat(target)
if (!s.isDirectory()) return false
await stat(path.join(target, '.git'))
return true
} catch {
return false
}
}
/** Build a PushSubscriptionRecord from an untrusted POST body, or null. */
function toSubscriptionRecord(body: Record<string, unknown>): PushSubscriptionRecord | null {
const endpoint = body['endpoint']
const keys = body['keys']
if (typeof endpoint !== 'string' || endpoint.length === 0) return null
if (keys === null || typeof keys !== 'object') return null
const k = keys as Record<string, unknown>
if (typeof k['p256dh'] !== 'string' || typeof k['auth'] !== 'string') return null
return { endpoint, keys: { p256dh: k['p256dh'], auth: k['auth'] }, createdAt: Date.now() }
}
/**
@@ -107,11 +184,28 @@ function safeSend(ws: WsWebSocket, data: string): void {
* both the HTTP server and the session manager.
*/
export function startServer(cfg: Config): { close(): Promise<void> } {
const manager = createSessionManager(cfg)
// A1: persistent push-subscription store + VAPID-signed push sender, injected
// into the manager (DI, M5) so the manager never imports push-service directly.
const subStore = loadSubscriptionStore(cfg.pushStorePath, cfg.pushMaxSubs)
const pushService = createPushService(cfg, subStore)
const manager = createSessionManager(cfg, pushService)
// H3: held PermissionRequest responses, keyed by sessionId. The express `res`
// is parked here until the client approves/rejects (or it times out).
const pendingApprovals = new Map<string, { res: Response; timer: ReturnType<typeof setTimeout> }>()
// Per-IP rate limiters for the new state-changing routes (SEC-H9).
const decisionLimiter = createRateLimiter(DECISION_RATE_MAX, RATE_LIMIT_WINDOW_MS)
const subscribeLimiter = createRateLimiter(SUBSCRIBE_RATE_MAX, RATE_LIMIT_WINDOW_MS)
// H3/A1: held PermissionRequest responses, keyed by sessionId. `res` is parked
// until approve/reject or timeout. `token`+`expiresAt` = the per-decision
// capability a remote device presents at /hook/decision (SEC-C1/M1); `gate`
// (B4) lets a late-joining client re-render the right affordance (M3).
interface PendingApproval {
res: Response
timer: ReturnType<typeof setTimeout>
token: string
expiresAt: number
gate: PermissionGate
}
const pendingApprovals = new Map<string, PendingApproval>()
function resolvePending(sessionId: string, decision: unknown): void {
const p = pendingApprovals.get(sessionId)
@@ -263,16 +357,25 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
res.status(403).end()
return
}
const body = (req.body ?? {}) as Record<string, unknown>
const ev = parseHookEvent(req.header('x-webterm-session'), req.body)
if (ev === null) {
res.status(400).end()
return
}
manager.handleHookEvent(ev.sessionId, ev.status, ev.detail)
// A4: pass event class + tool name so the manager appends a sanitized
// timeline entry. Not a held gate → pending/gate stay undefined.
const tool = typeof body['tool_name'] === 'string' ? body['tool_name'] : undefined
manager.handleHookEvent(ev.sessionId, ev.status, ev.detail, undefined, undefined, ev.eventClass, tool)
// A1: DONE push on terminal-completing events (best-effort; honors NOTIFY_DONE).
if (ev.eventClass === 'Stop' || ev.eventClass === 'SessionEnd') {
const session = manager.get(ev.sessionId)
if (session !== undefined) void pushService.notify(session, 'done')
}
res.status(204).end()
})
// H3: PermissionRequest hook — held until the client approves/rejects. The
// H3/A1: PermissionRequest hook — held until the client approves/rejects. The
// hook's curl writes our response (the decision JSON) to stdout for Claude.
app.post('/hook/permission', express.json({ limit: '64kb' }), (req, res) => {
if (!isLoopback(req.socket.remoteAddress ?? '')) {
@@ -284,22 +387,205 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
const tool = typeof body['tool_name'] === 'string' ? body['tool_name'] : undefined
const session = typeof sessionId === 'string' ? manager.get(sessionId) : undefined
// Nobody watching this tab (no session / no clients) → let Claude prompt itself.
if (sessionId === undefined || session === undefined || session.clients.size === 0) {
if (sessionId === undefined || session === undefined) {
res.json({}) // no such session → let Claude prompt itself
return
}
// C1 hold gate (SEC-C2): hold if a client is watching OR push is enabled with
// ≥1 subscription (so "walk away with zero tabs" still triggers lock-screen
// approval); otherwise fall through to Claude's own prompt.
const hasWatcher = session.clients.size > 0
const hasPushTarget = pushService.isEnabled() && subStore.list().length > 0
if (!hasWatcher && !hasPushTarget) {
res.json({})
return
}
// B4: an ExitPlanMode request is a 'plan' gate (three-way), else a 'tool' gate.
const gate: PermissionGate = tool === 'ExitPlanMode' ? 'plan' : 'tool'
resolvePending(sessionId, {}) // clear any stale hold for this session
const token = randomUUID()
const expiresAt = Date.now() + cfg.decisionTokenTtlMs
const timer = setTimeout(() => {
pendingApprovals.delete(sessionId)
res.json({}) // timeout → fall back to Claude's interactive prompt
manager.handleHookEvent(sessionId, 'idle')
}, cfg.permTimeoutMs)
pendingApprovals.set(sessionId, { res, timer })
pendingApprovals.set(sessionId, { res, timer, token, expiresAt, gate })
// Show the approve/reject affordance on the client.
manager.handleHookEvent(sessionId, 'waiting', tool, true)
// A1: push the lock-screen approval (carrying the capability token) to every
// subscribed device. Best-effort — failures are logged inside push-service.
void pushService.notify(session, 'needs-input', token)
// Show the approve/reject affordance on every attached client.
manager.handleHookEvent(sessionId, 'waiting', tool, true, gate)
})
// ── A1 push subscription + lock-screen decision routes ────────────────────
// Public VAPID key for SW subscription. 503 when push is disabled (graceful).
app.get('/push/vapid-key', (_req, res) => {
if (!pushService.isEnabled() || cfg.vapidPublicKey === undefined) {
res.status(503).end()
return
}
res.json({ publicKey: cfg.vapidPublicKey })
})
// Store a browser PushSubscription (state-changing → Origin guard + rate limit).
app.post('/push/subscribe', express.json({ limit: '8kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!subscribeLimiter(req.socket.remoteAddress ?? '', Date.now())) {
res.status(429).end()
return
}
const record = toSubscriptionRecord((req.body ?? {}) as Record<string, unknown>)
if (record === null) {
res.status(400).end()
return
}
try {
subStore.add(record)
await subStore.persist()
} catch {
res.status(400).end()
return
}
res.status(204).end()
})
// Remove a stored subscription (state-changing → Origin guard + rate limit).
app.delete('/push/subscribe', express.json({ limit: '8kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!subscribeLimiter(req.socket.remoteAddress ?? '', Date.now())) {
res.status(429).end()
return
}
const endpoint = (req.body ?? {}) as Record<string, unknown>
const value = endpoint['endpoint']
if (typeof value !== 'string' || value.length === 0) {
res.status(400).end()
return
}
subStore.remove(value)
await subStore.persist()
res.status(204).end()
})
// Remote lock-screen Allow/Deny (SEC-C1): called by a REMOTE device's SW (not
// loopback) → guard with Origin + a per-decision capability token (stale/
// mismatch → 403).
app.post('/hook/decision', express.json({ limit: '4kb' }), (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!decisionLimiter(req.socket.remoteAddress ?? '', Date.now())) {
res.status(429).end()
return
}
const body = (req.body ?? {}) as Record<string, unknown>
const sessionId = typeof body['sessionId'] === 'string' ? body['sessionId'] : undefined
const decision = body['decision']
const token = typeof body['token'] === 'string' ? body['token'] : undefined
if (sessionId === undefined || token === undefined || (decision !== 'allow' && decision !== 'deny')) {
res.status(400).end()
return
}
const pending = pendingApprovals.get(sessionId)
if (pending === undefined || pending.token !== token || Date.now() > pending.expiresAt) {
res.status(403).end() // SEC-C1/M1: missing / mismatched / stale token
return
}
resolvePending(sessionId, permDecision(decision))
manager.handleHookEvent(sessionId, 'working', undefined, false)
res.status(204).end() // SW does not read the body (review #14)
})
// ── A4 timeline (read-only discovery, no Origin guard) ────────────────────
app.get('/live-sessions/:id/events', (req, res) => {
if (!cfg.timelineEnabled) {
res.json([]) // AC-A4.6: capture/serve disabled → empty
return
}
const session = manager.get(req.params.id)
if (session === undefined) {
res.status(404).end()
return
}
res.json(session.timeline)
})
// ── B1 read-only git diff (no Origin guard; same threat model as /projects) ─
app.get('/projects/diff', async (req, res) => {
const target = req.query['path']
if (typeof target !== 'string' || target === '') {
res.status(400).json({ error: 'path query parameter is required' })
return
}
if (!(await isValidGitDir(target))) {
res.status(404).json({ error: 'project not found' }) // SEC-H7 three-prong
return
}
try {
const staged = req.query['staged'] === '1'
res.json(await getDiff(target, { staged, cfg }))
} catch (err) {
console.error('[server] /projects/diff failed:', err instanceof Error ? err.message : String(err))
res.status(500).json({ error: 'failed to read diff' })
}
})
// ── B2 statusLine telemetry ingest (loopback only, SEC-H1) ────────────────
app.post('/hook/status', express.json({ limit: '64kb' }), (req, res) => {
if (!isLoopback(req.socket.remoteAddress ?? '')) {
res.status(403).end()
return
}
const telemetry = parseStatusLine(req.body)
if (telemetry === null) {
res.status(400).end()
return
}
const sessionId = req.header('x-webterm-session')
if (typeof sessionId === 'string' && sessionId.length > 0) {
manager.handleStatusLine(sessionId, telemetry)
}
res.status(204).end()
})
// ── B3 create a git worktree (the only write-to-disk feature → Origin guard) ─
app.post('/projects/worktree', express.json({ limit: '4kb' }), async (req, res) => {
if (!requireAllowedOrigin(req, res)) return
if (!cfg.worktreeEnabled) {
res.status(403).json({ error: 'Worktree creation is disabled.' })
return
}
const body = (req.body ?? {}) as Record<string, unknown>
const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined
const branch = typeof body['branch'] === 'string' ? body['branch'] : undefined
const base = typeof body['base'] === 'string' && body['base'] !== '' ? body['base'] : undefined
if (repoPath === undefined || branch === undefined) {
res.status(400).json({ error: 'path and branch are required' })
return
}
// Audit (B3.4): who/what, sanitized + truncated (never raw control chars).
console.error(`[server] worktree create: branch=${sanitizeForLog(branch)} path=${sanitizeForLog(repoPath)}`)
const result = await createWorktree(repoPath, branch, {
base,
worktreeRoot: cfg.worktreeRoot,
timeoutMs: cfg.worktreeTimeoutMs,
})
if (result.ok) {
res.status(200).json({ ok: true, path: result.path, branch: result.branch })
return
}
res.status(result.status ?? 500).json({ error: result.error ?? 'Failed to create the worktree.' })
})
// ── GET /config/ui (review #4) — client-readable UI config (read-only) ────
app.get('/config/ui', (_req, res) => {
const uiConfig: UiConfig = { allowAutoMode: cfg.allowAutoMode }
res.json(uiConfig)
})
// ── HTTP server ───────────────────────────────────────────────────────────
@@ -313,9 +599,11 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
maxPayload: cfg.maxPayloadBytes, // L5: cap single WS frame size
})
// ── Idle reaper ───────────────────────────────────────────────────────────
// ── Idle reaper (+ A5 stuck sweep, H4 — no new timer) ─────────────────────
const reapTimer = setInterval(() => {
manager.reapIdle(Date.now())
const now = Date.now()
manager.reapIdle(now)
manager.sweepStuck(now)
}, cfg.reapIntervalMs)
// Don't let this timer prevent the process from exiting.
reapTimer.unref()
@@ -421,6 +709,17 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
// Confirm the attach to the client.
safeSend(ws, serialize({ type: 'attached', sessionId: session.meta.id }))
// M3 / AC-A1.3: a late-joining device immediately sees a still-held
// approval (the manager re-sent telemetry + status; the pending/gate
// portion lives here because the server owns pendingApprovals).
const heldApproval = pendingApprovals.get(boundSessionId)
if (heldApproval !== undefined) {
safeSend(
ws,
serialize({ type: 'status', status: 'waiting', pending: true, gate: heldApproval.gate }),
)
}
return
}
@@ -437,8 +736,12 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
// Latest-writer-wins: this device's dims drive the shared PTY size.
setClientDims(session, ws, msg.cols, msg.rows)
} else if (msg.type === 'approve') {
// H3: resolve the held PermissionRequest with allow.
resolvePending(boundSessionId, permDecision('allow'))
// H3/B4: resolve the held PermissionRequest with allow, carrying the
// optional permission mode. SEC-M5: downgrade the high-risk 'auto' mode
// to 'default' unless ALLOW_AUTO_MODE is set (even if the FE hid it).
let mode = parseApproveMode(text)
if (mode === 'auto' && !cfg.allowAutoMode) mode = 'default'
resolvePending(boundSessionId, permDecision('allow', mode))
manager.handleHookEvent(boundSessionId, 'working', undefined, false)
} else if (msg.type === 'reject') {
resolvePending(boundSessionId, permDecision('deny'))

View File

@@ -30,13 +30,18 @@ import type {
Config,
Dims,
LiveSessionInfo,
NotifyService,
PermissionGate,
ServerMessage,
Session,
SessionManager,
StatusTelemetry,
WebSocketLike,
} from '../types.js';
import { WS_OPEN } from '../types.js';
import { serialize } from '../protocol.js';
import { createSession, attachWs, broadcast, kill } from './session.js';
import { appendEvent, makeTimelineEvent } from './timeline.js';
import { hasSession, tmuxName } from './tmux.js';
/**
@@ -57,8 +62,16 @@ function sendIfOpen(ws: WebSocketLike | null, msg: Parameters<typeof serialize>[
* (add, delete) rather than in-place modification of the table structure.
* Individual Session objects do carry mutable fields (attachedWs, detachedAt,
* lastOutputAt, exitedAt, exitCode) by design — those are runtime handles.
*
* `notifyService` is injected (DI, M5) so the manager never imports push-service
* directly (avoids a manager↔push-service cycle). sweepStuck uses it to push the
* 'stuck' signal; when it is undefined (VAPID unset / tests) stuck detection still
* updates status + broadcasts, it just doesn't send a push.
*/
export function createSessionManager(cfg: Config): SessionManager {
export function createSessionManager(
cfg: Config,
notifyService?: NotifyService,
): SessionManager {
let sessions: Map<string, Session> = new Map();
/**
@@ -117,6 +130,14 @@ export function createSessionManager(cfg: Config): SessionManager {
// ── Case 2: hit a live session → JOIN it (multi-device sharing) ───────────
if (existing !== undefined && existing.exitedAt === null) {
attachWs(existing, ws);
// M3 / AC-B2.3: a late-joining device immediately gets the current
// telemetry (if any) and status so it doesn't wait for the next
// hook/statusLine report to refresh. The `pending`/`gate` portion of the
// status is re-sent by the server layer (it owns pendingApprovals).
if (existing.telemetry !== null) {
sendIfOpen(ws, { type: 'telemetry', telemetry: existing.telemetry });
}
sendIfOpen(ws, { type: 'status', status: existing.claudeStatus });
return existing;
}
@@ -168,6 +189,7 @@ export function createSessionManager(cfg: Config): SessionManager {
cwd: s.cwd,
cols: s.pty.cols,
rows: s.pty.rows,
telemetry: s.telemetry, // B2: latest telemetry for the thumbnail wall
}))
.sort((a, b) => b.createdAt - a.createdAt);
}
@@ -190,27 +212,77 @@ export function createSessionManager(cfg: Config): SessionManager {
}
/**
* H2: a Claude Code hook reported activity for `sessionId`. Record it and push
* a `status` frame to the attached ws (no-op if the session is gone/detached).
* H2/H3: a Claude Code hook reported activity for `sessionId`. Update the
* status, append a timeline event (A4) when `eventClass` is supplied and the
* timeline is enabled, and broadcast a `status` frame — carrying `gate` (B4)
* when the held approval is a 'tool'/'plan' gate. No-op for an unknown session.
*/
function handleHookEvent(
sessionId: string,
status: ClaudeStatus,
detail?: string,
pending?: boolean,
gate?: PermissionGate,
eventClass?: string,
toolName?: string,
): void {
const session = sessions.get(sessionId);
if (session === undefined) return;
session.claudeStatus = status;
const msg: { type: 'status'; status: ClaudeStatus; detail?: string; pending?: boolean } = {
type: 'status',
status,
};
// A4: derive a bounded, sanitized timeline event from the raw hook name.
// makeTimelineEvent whitelist-gates the event class (unknown → null = drop).
if (cfg.timelineEnabled && eventClass !== undefined) {
const ev = makeTimelineEvent(eventClass, toolName, Date.now());
if (ev !== null) {
session.timeline = appendEvent(session.timeline, ev, cfg.timelineMax);
}
}
const msg: Extract<ServerMessage, { type: 'status' }> = { type: 'status', status };
if (detail !== undefined) msg.detail = detail;
if (pending) msg.pending = true;
if (gate !== undefined) msg.gate = gate;
broadcast(session, msg);
}
/**
* B2: store the latest statusLine telemetry for a session and broadcast a
* `telemetry` frame to every attached client. No-op for an unknown session.
*/
function handleStatusLine(id: string, telemetry: StatusTelemetry): void {
const session = sessions.get(id);
if (session === undefined) return;
session.telemetry = telemetry;
broadcast(session, { type: 'telemetry', telemetry });
}
/**
* A5 (H4): on the existing reaper tick (no new timer), mark live, non-idle
* sessions whose output has been silent past STUCK_TTL as 'stuck'. Covers BOTH
* attached and detached living sessions. Sets the status, broadcasts it (§3.4
* review #5 — not just a notify), and pushes a single 'stuck' alert per silent
* round (re-armed by the next pty output in session.ts). Disabled when
* stuckAlert is off or stuckTtlMs ≤ 0.
*/
function sweepStuck(now: number): void {
if (!cfg.stuckAlert || cfg.stuckTtlMs <= 0) return;
for (const session of sessions.values()) {
if (session.exitedAt !== null) continue; // already exited
if (session.claudeStatus === 'idle') continue; // legitimately done
if (session.stuckNotified) continue; // already alerted this round
if (now - session.lastOutputAt <= cfg.stuckTtlMs) continue; // still fresh
session.claudeStatus = 'stuck';
session.stuckNotified = true;
broadcast(session, { type: 'status', status: 'stuck' });
void notifyService?.notify(session, 'stuck').catch((err: unknown) => {
console.error('[manager] stuck notification failed', err);
});
}
}
/**
* Reclaim detached sessions whose liveness window has expired (M3).
*
@@ -259,5 +331,15 @@ export function createSessionManager(cfg: Config): SessionManager {
sessions = new Map();
}
return { handleAttach, get, list, killById, handleHookEvent, reapIdle, shutdown };
return {
handleAttach,
get,
list,
killById,
handleHookEvent,
handleStatusLine,
sweepStuck,
reapIdle,
shutdown,
};
}

View File

@@ -32,6 +32,7 @@ import type {
Session,
SessionMeta,
ServerMessage,
TimelineEvent,
WebSocketLike,
} from '../types.js';
import { WS_OPEN } from '../types.js';
@@ -95,6 +96,9 @@ export function createSession(
...process.env,
WEBTERM_SESSION: id,
WEBTERM_HOOK_URL: `http://127.0.0.1:${cfg.port}/hook`,
// B2: statusLine scripts POST telemetry here; WEBTERM_NTFY_* vars forwarded
// via ...process.env spread above (H3 — token set in env, not here).
WEBTERM_STATUSLINE_URL: `http://127.0.0.1:${cfg.port}/hook/status`,
},
});
@@ -116,12 +120,25 @@ export function createSession(
cwd: cwd ?? null,
tmuxName: tName,
pty,
// v0.7 Walk-away Workbench fields (T-spawn-env):
timeline: Object.freeze([] as TimelineEvent[]),
stuckNotified: false, // A5: re-armed to false by each pty output
telemetry: null, // B2: updated by manager.handleStatusLine
};
// onData: persist to scrollback, refresh liveness, broadcast to all clients.
pty.onData((chunk) => {
session.buffer.append(chunk);
session.lastOutputAt = Date.now();
// A5 §3.4: re-arm the stuck flag on every output so each silent round can
// alert at most once. If we were stuck, recover to 'working' and broadcast
// so clients clear the stuck badge immediately — this is the ONLY reliable
// place to do this (same as lastOutputAt; T-manager never sees raw output).
session.stuckNotified = false;
if (session.claudeStatus === 'stuck') {
session.claudeStatus = 'working';
broadcast(session, { type: 'status', status: 'working' });
}
broadcast(session, { type: 'output', data: chunk });
});

143
src/session/timeline.ts Normal file
View File

@@ -0,0 +1,143 @@
/**
* src/session/timeline.ts — N-timeline (A4)
*
* Pure functions for the bounded, timestamped activity ring.
* Zero DOM dependencies; importable from both backend and tests.
*
* Security:
* SEC-H6 — sanitizeField strips control chars and truncates tool names/paths
* SEC-M8 — appendEvent enforces the ring cap on every call
*/
import type { TimelineClass, TimelineEvent } from '../types.js';
// ── constants ─────────────────────────────────────────────────────────────────
/** Tools whose PostToolUse event gets an "edited" flavour label. */
const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
/** Whitelisted Claude Code hook event names. Events outside this set are
* dropped in makeTimelineEvent (unknown hooks = don't pollute the timeline). */
const WHITELISTED_EVENTS = new Set([
'PreToolUse',
'PostToolUse',
'PermissionRequest',
'Notification',
'Stop',
'SessionEnd',
'UserPromptSubmit',
]);
/** Maps hook event names to semantic TimelineClass values (§3.1 review #1).
* The FE consumes `class` directly for icon/colour — never re-derives. */
const CLASS_MAP: Readonly<Record<string, TimelineClass>> = {
PreToolUse: 'tool',
PostToolUse: 'tool',
PermissionRequest: 'waiting',
Notification: 'waiting',
Stop: 'done',
SessionEnd: 'done',
UserPromptSubmit: 'user',
};
// ── sanitizeField ─────────────────────────────────────────────────────────────
/**
* Strip ASCII control characters (0x000x1f) from `s`, then truncate to `max`
* characters. Used for tool names and any other hook-supplied string fields.
* (SEC-H6)
*/
export function sanitizeField(s: string, max = 200): string {
// eslint-disable-next-line no-control-regex
return s.replace(/[\x00-\x1f]/g, '').slice(0, max);
}
// ── deriveClass ───────────────────────────────────────────────────────────────
/**
* Map a raw Claude Code hook event name to its semantic TimelineClass (§3.1).
* Falls back to 'tool' for any event that doesn't appear in the map —
* makeTimelineEvent already whitelist-gates, so this is just a safety net.
*/
export function deriveClass(eventName: string): TimelineClass {
return CLASS_MAP[eventName] ?? 'tool';
}
// ── deriveLabel ───────────────────────────────────────────────────────────────
/**
* Produce a human-language label for a hook event.
* `toolName` is expected to already be sanitized when called from
* makeTimelineEvent; callers must not pass raw hook data here directly.
*/
export function deriveLabel(eventName: string, toolName?: string): string {
switch (eventName) {
case 'PreToolUse':
return toolName ? `using ${toolName}` : 'using tool';
case 'PostToolUse':
if (toolName && EDIT_TOOLS.has(toolName)) {
return `edited with ${toolName}`;
}
return toolName ? `ran ${toolName}` : 'ran tool';
case 'PermissionRequest':
case 'Notification':
return 'waiting for approval';
case 'Stop':
case 'SessionEnd':
return 'done';
case 'UserPromptSubmit':
return 'user input';
default:
return 'activity';
}
}
// ── makeTimelineEvent ─────────────────────────────────────────────────────────
/**
* Build a TimelineEvent from raw hook data, or return null if the event name
* is not in the whitelist (unknown hooks are silently dropped).
*
* Sanitizes `toolName` via sanitizeField before embedding it in the event or
* passing it to deriveLabel (SEC-H6).
*/
export function makeTimelineEvent(
eventName: string,
toolName: string | undefined,
at: number,
): TimelineEvent | null {
if (!WHITELISTED_EVENTS.has(eventName)) return null;
const sanitizedToolName =
toolName !== undefined ? sanitizeField(toolName) : undefined;
return {
at,
class: deriveClass(eventName),
toolName: sanitizedToolName,
label: deriveLabel(eventName, sanitizedToolName),
};
}
// ── appendEvent ───────────────────────────────────────────────────────────────
/**
* Return a NEW array with `ev` appended to `events`, evicting the oldest
* entries if the length would exceed `maxLen`. Never mutates `events` (SEC-M8).
*/
export function appendEvent(
events: readonly TimelineEvent[],
ev: TimelineEvent,
maxLen: number,
): readonly TimelineEvent[] {
const next = [...events, ev];
if (next.length > maxLen) {
return next.slice(next.length - maxLen);
}
return next;
}

View File

@@ -40,6 +40,35 @@ export interface Config {
readonly projectScanTtlMs: number; // PROJECT_SCAN_TTL, default 10000 (discovery cache)
readonly projectDirtyCheck: boolean; // PROJECT_DIRTY_CHECK, default true (git status per repo)
readonly editorCmd: string; // EDITOR_CMD, default 'code' — POST /open-in-editor launches `<editorCmd> <repoPath>` on the host
// v0.7 Walk-away Workbench — 21 new fields (impl: src/config.ts T-config)
// A1 push notifications (vapid* are SECRETS — never log/expose to clients)
readonly vapidPublicKey: string | undefined; // VAPID_PUBLIC_KEY
readonly vapidPrivateKey: string | undefined; // VAPID_PRIVATE_KEY (secret)
readonly vapidSubject: string | undefined; // VAPID_SUBJECT (mailto:/url)
readonly pushStorePath: string; // PUSH_STORE_PATH, default ~/.web-terminal-push-subs.json
readonly pushMaxSubs: number; // PUSH_MAX_SUBS, default 50
readonly notifyDone: boolean; // NOTIFY_DONE, default true
readonly notifyDnd: boolean; // NOTIFY_DND, default false
readonly decisionTokenTtlMs: number; // DECISION_TOKEN_TTL_MS, default = permTimeoutMs
// A4 activity timeline
readonly timelineMax: number; // TIMELINE_MAX, default 200
readonly timelineEnabled: boolean; // TIMELINE_ENABLED, default true
// A5 stuck detection
readonly stuckTtlMs: number; // STUCK_TTL (seconds env) → ms, default 600s
readonly stuckAlert: boolean; // STUCK_ALERT, default true
// B1 git diff
readonly diffTimeoutMs: number; // DIFF_TIMEOUT_MS, default 2000
readonly diffMaxBytes: number; // DIFF_MAX_BYTES, default 2MB
readonly diffMaxFiles: number; // DIFF_MAX_FILES, default 300
// B2 statusLine telemetry
readonly statuslineTtlMs: number; // STATUSLINE_TTL_MS, default 30000
// B3 git worktree creation
readonly worktreeEnabled: boolean; // WORKTREE_ENABLED, default true
readonly worktreeRoot: string | undefined; // WORKTREE_ROOT (undefined → computed)
readonly worktreeTimeoutMs: number; // WORKTREE_TIMEOUT_MS, default 10000
// B4 permission-mode relay
readonly defaultPermissionMode: PermissionMode; // DEFAULT_PERMISSION_MODE, default 'default'
readonly allowAutoMode: boolean; // ALLOW_AUTO_MODE, default false (SEC-M5)
}
/** process.env is structurally assignable to this; keeps the file free of `NodeJS.*`. */
@@ -51,26 +80,43 @@ export type EnvLike = Readonly<Record<string, string | undefined>>;
/* ─────────────────────── protocol (§3.2) ─────────────────────── */
/** client → server. approve/reject (H3) resolve a held PermissionRequest.
* attach.cwd (M6) = spawn a new session in this directory ("new tab here"). */
* attach.cwd (M6) = spawn a new session in this directory ("new tab here").
* approve.mode (B4) = permission mode to write back when resolving a `plan`
* gate (e.g. approve+auto → 'acceptEdits'); omitted for ordinary tool gates. */
export type ClientMessage =
| { type: 'attach'; sessionId: string | null; cwd?: string }
| { type: 'input'; data: string }
| { type: 'resize'; cols: number; rows: number }
| { type: 'approve' }
| { type: 'approve'; mode?: PermissionMode }
| { type: 'reject' };
/** Claude Code activity, derived from hooks (H2). 'unknown' = no hook signal yet. */
export type ClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown';
/** Claude Code activity, derived from hooks (H2). 'unknown' = no hook signal yet.
* 'stuck' (A5) = output silent past STUCK_TTL while not idle/exited; set by
* manager.sweepStuck and re-armed to 'working' on the next pty output. */
export type ClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown' | 'stuck';
/** Which kind of held approval a `status` carries (B4). 'plan' = an ExitPlanMode
* gate (three-way approve/auto/keep-planning); 'tool' = an ordinary tool gate. */
export type PermissionGate = 'tool' | 'plan';
/** server → client. exit.code = shell code; -1 when spawn never succeeded (M4).
* exit.reason optional normally, REQUIRED on spawn failure / abnormal exit.
* status (H2/H3) = Claude Code activity; `pending` true when a tool approval
* is held server-side and the client can approve/reject it. */
* is held server-side and the client can approve/reject it; `gate` (B4) tells
* the client whether the held approval is a 'tool' or 'plan' gate.
* telemetry (B2) = latest statusLine telemetry broadcast for this session. */
export type ServerMessage =
| { type: 'attached'; sessionId: string }
| { type: 'output'; data: string }
| { type: 'exit'; code: number; reason?: string }
| { type: 'status'; status: ClaudeStatus; detail?: string; pending?: boolean };
| {
type: 'status';
status: ClaudeStatus;
detail?: string;
pending?: boolean;
gate?: PermissionGate;
}
| { type: 'telemetry'; telemetry: StatusTelemetry };
/** parseClientMessage result — never throws; errors flow here (§5.3). */
export type ParseResult =
@@ -172,6 +218,15 @@ export interface Session {
cwd: string | null;
/** tmux session name (H1) when running under tmux, else null. */
readonly tmuxName: string | null;
/** Bounded, timestamped activity ring (A4); newest appended, oldest evicted at
* cfg.timelineMax. Mutable runtime handle on the immutable meta (like buffer).
* Replaced wholesale via appendEvent (never mutated in place). */
timeline: readonly TimelineEvent[];
/** A5: true once a stuck alert fired this round; re-armed (→false) by the next
* pty.onData so each silent round alerts at most once. */
stuckNotified: boolean;
/** B2: latest statusLine telemetry for this session; null until first report. */
telemetry: StatusTelemetry | null;
readonly pty: IPty;
}
@@ -196,6 +251,7 @@ export interface LiveSessionInfo {
cwd: string | null;
cols: number; // current PTY size (for the manage page)
rows: number;
telemetry?: StatusTelemetry | null; // B2: latest telemetry for the thumbnail wall
}
/* ───────────────── project manager (v0.6, §4.3 FEATURE doc) ──────────────── */
@@ -262,20 +318,179 @@ export interface SessionManager {
/** Kill a session by id (manage page): close its clients, kill the PTY, drop it.
* Returns true if a session was found and killed. */
killById(id: string): boolean;
/** Set a session's Claude status (from a hook) and push it to the attached ws (H2/H3). */
/** Set a session's Claude status (from a hook), append a timeline event (A4)
* when eventClass is supplied + timeline is enabled, and broadcast to all
* attached clients (H2/H3). gate (B4) marks a 'tool'/'plan' approval gate. */
handleHookEvent(
sessionId: string,
status: ClaudeStatus,
detail?: string,
pending?: boolean,
gate?: PermissionGate,
eventClass?: string,
toolName?: string,
): void;
/** B2: store the latest statusLine telemetry for a session and broadcast a
* `telemetry` message to all attached clients. */
handleStatusLine(id: string, telemetry: StatusTelemetry): void;
/** A5: on the existing reaper tick (no new timer, H4), mark live non-idle
* sessions whose output has been silent past STUCK_TTL as 'stuck', broadcast,
* and notify once per silent round (re-armed by the next pty output). */
sweepStuck(now: number): void;
/** reclaim when now - max(detachedAt, lastOutputAt) > idleTtlMs (M3). Returns count. */
reapIdle(now: number): number;
shutdown(): void;
}
// impl anchor [src/session/manager.ts]:
// export function createSessionManager(cfg: Config): SessionManager
// export function createSessionManager(cfg: Config, notifyService?: NotifyService): SessionManager
// notifyService is injected (DI, M5) so the manager never imports push-service
// directly (avoids a manager↔push-service cycle); sweepStuck uses it for 'stuck'.
/* ───────────── v0.7 Walk-away Workbench (FEATURE_WALKAWAY_WORKBENCH) ─────────────
*
* The five FE↔BE contract resolutions from PLAN_WALKAWAY_WORKBENCH §3, frozen
* here so the frontend and backend agree on ONE spelling/shape. Field names for
* StatusTelemetry (B2) and the value set for PermissionMode (B4) are derived
* from the R0 statusLine / --permission-mode research findings.
*/
/* ── B4 permission-mode relay (§3.5) ── */
/** --permission-mode values the relay exposes (R0-confirmed). 'auto' is its own
* Claude Code mode (background safety checks), distinct from bypassPermissions
* (the high-risk modes dontAsk/bypassPermissions are intentionally not surfaced).
* Validated at the WS boundary; gated server-side by ALLOW_AUTO_MODE (SEC-M5). */
export type PermissionMode = 'default' | 'acceptEdits' | 'plan' | 'auto';
/* ── A1 push notifications (§3.3, §A1) ── */
/** The three proactive signals pushed to the phone (§3.3 / §A1). */
export type NotifyClass = 'needs-input' | 'done' | 'stuck';
/** Outbound push body — ONE shape: push-service sends it, sw-push.js reads `cls`
* (§3.3 review #3). Minimal by design: no raw terminal output, no secrets.
* `token` is the per-decision capability token (only on needs-input). */
export interface PushPayload {
sessionId: string;
toolName?: string;
detail?: string;
token?: string;
cls: NotifyClass;
}
/** A stored browser PushSubscription (persisted to a 600-perm JSON, never via GET). */
export interface PushSubscriptionRecord {
endpoint: string;
keys: { p256dh: string; auth: string };
createdAt: number;
}
/** The notification provider, injected into the manager (DI, M5) so it never
* imports push-service directly. isEnabled() is false when VAPID keys are unset
* (graceful disable). notify() honors DND/NOTIFY_DONE internally. */
export interface NotifyService {
isEnabled(): boolean;
notify(session: Session, cls: NotifyClass, token?: string): Promise<void>;
}
/* ── B2 statusLine telemetry (§3.5, derived from R0 schema) ── */
/** Per-session telemetry derived server-side from the Claude Code statusLine
* stdin JSON (R0). All metric fields are optional — parsing is tolerant (missing
* field → undefined, never throw); `at` is the server receive time (Date.now()).
* Field mapping (R0 → here): context_window.used_percentage→contextUsedPct,
* cost.total_cost_usd→costUsd, cost.total_lines_added/removed→linesAdded/Removed,
* model.display_name→model, effort.level→effort, pr→pr, rate_limits→rate. */
export interface StatusTelemetry {
contextUsedPct?: number;
costUsd?: number;
linesAdded?: number;
linesRemoved?: number;
model?: string;
effort?: string;
pr?: { number: number; url: string; reviewState?: string };
rate?: { fiveHourPct?: number; sevenDayPct?: number };
at: number;
}
/* ── A4 activity timeline (§3.1) ── */
/** Server-DERIVED semantic class for a timeline event (review #1). The raw hook
* name is mapped by timeline.ts deriveClass(); the FE consumes `class` directly
* for icon/color and never re-derives from hook names. */
export type TimelineClass = 'tool' | 'waiting' | 'done' | 'stuck' | 'user';
/** One bounded, timestamped activity entry (A4). `label` is the server-derived
* human phrase ("ran Bash", "edited 3 files"); `toolName` is sanitized (≤200,
* control chars stripped). `at` is the server Date.now() at ingest. */
export interface TimelineEvent {
at: number;
class: TimelineClass;
toolName?: string;
label: string;
}
/* ── B1 read-only git diff (§3.2) ── */
/** ONE spelling (semantic words, review #2). Parsing lives ONLY in the backend
* (src/http/diff.ts); public/diff.ts is render-only. */
export type DiffLineKind = 'added' | 'removed' | 'context' | 'hunk' | 'meta';
export interface DiffLine {
kind: DiffLineKind;
text: string;
}
export interface DiffHunk {
header: string;
lines: DiffLine[];
}
export type FileStatus =
| 'modified'
| 'added'
| 'deleted'
| 'renamed'
| 'binary'
| 'untracked';
export interface DiffFile {
oldPath: string;
newPath: string;
status: FileStatus;
added: number;
removed: number;
binary: boolean;
hunks: DiffHunk[];
}
export interface DiffResult {
files: DiffFile[];
staged: boolean;
truncated: boolean;
}
/* ── B3 worktree creation (§3.5) ── */
/** Result of POST /projects/worktree (B3). `error` carries a safe message only
* (never raw git stderr, SEC-M10); `status` is the HTTP status to return. */
export interface CreateWorktreeResult {
ok: boolean;
path?: string;
branch?: string;
status?: number;
error?: string;
}
/* ── GET /config/ui (review #4) ── */
/** Client-readable UI configuration (GET /config/ui). `allowAutoMode` mirrors
* the server ALLOW_AUTO_MODE gate so the FE can hide the high-risk 'auto'
* permission mode when the server forbids it (SEC-M5). */
export interface UiConfig {
allowAutoMode: boolean;
}
/* ─────────────────────── frontend (§5/§6.3) ──────────────────── */