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:
176
src/config.ts
176
src/config.ts
@@ -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 (A1–A5, B1–B4).
|
||||
* 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
365
src/http/diff.ts
Normal 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 }
|
||||
}
|
||||
@@ -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
131
src/http/statusline.ts
Normal 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 }),
|
||||
};
|
||||
}
|
||||
@@ -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
174
src/push/push-service.ts
Normal 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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
112
src/push/subscription-store.ts
Normal file
112
src/push/subscription-store.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
375
src/server.ts
375
src/server.ts
@@ -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'))
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
143
src/session/timeline.ts
Normal 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 (0x00–0x1f) 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;
|
||||
}
|
||||
231
src/types.ts
231
src/types.ts
@@ -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) ──────────────────── */
|
||||
|
||||
|
||||
Reference in New Issue
Block a user