diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 640595b3..e4b2aab9 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -358,6 +358,52 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } } + /** + * Build the array of environment export commands shared by createSession() and respawnPane(). + * Includes locale, mux markers, session identity, and API URL. + */ + private buildEnvExports(sessionId: string, muxName: string, mode: SessionMode): string[] { + const exports = [ + 'export LANG=en_US.UTF-8', + 'export LC_ALL=en_US.UTF-8', + 'unset COLORTERM', + 'export CODEMAN_MUX=1', + `export CODEMAN_SESSION_ID=${sessionId}`, + `export CODEMAN_MUX_NAME=${muxName}`, + `export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`, + ]; + // Only unset CLAUDECODE for Claude sessions + if (mode === 'claude') exports.splice(2, 0, 'unset CLAUDECODE'); + return exports; + } + + /** + * Resolve the CLI binary directory and return the PATH export prefix string. + * Returns '' if no override is needed (shell mode) or the binary dir is not found. + * In createSession(), a missing binary dir throws — the caller handles that separately. + */ + private buildPathExport(mode: SessionMode): { pathExport: string; dir: string | null } { + if (mode === 'claude') { + const dir = findClaudeDir(); + return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; + } + if (mode === 'opencode') { + const dir = resolveOpenCodeDir(); + return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir }; + } + return { pathExport: '', dir: null }; + } + + /** + * Configure OpenCode-specific environment on a tmux session. + * Sets sensitive API keys and config content via tmux setenv + * (not visible in ps output or tmux history, inherited by panes). + */ + private _configureOpenCode(muxName: string, openCodeConfig?: OpenCodeConfig): void { + setOpenCodeEnvVars(muxName); + setOpenCodeConfigContent(muxName, openCodeConfig); + } + /** * Creates a new tmux session wrapping Claude CLI or a shell. * In test mode: creates an in-memory session only (no real tmux session). @@ -402,33 +448,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } // Resolve CLI binary directory based on mode - let pathExport = ''; - if (mode === 'claude') { - const claudeDir = findClaudeDir(); - if (!claudeDir) { - throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash'); - } - pathExport = `export PATH="${claudeDir}:$PATH" && `; - } else if (mode === 'opencode') { - const openCodeDir = resolveOpenCodeDir(); - if (!openCodeDir) { - throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'); - } - pathExport = `export PATH="${openCodeDir}:$PATH" && `; + const { pathExport, dir: cliDir } = this.buildPathExport(mode); + if (mode === 'claude' && !cliDir) { + throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash'); + } + if (mode === 'opencode' && !cliDir) { + throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'); } - const envExports = [ - 'export LANG=en_US.UTF-8', - 'export LC_ALL=en_US.UTF-8', - 'unset COLORTERM', - 'export CODEMAN_MUX=1', - `export CODEMAN_SESSION_ID=${sessionId}`, - `export CODEMAN_MUX_NAME=${muxName}`, - `export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`, - ]; - // Only unset CLAUDECODE for Claude sessions - if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE'); - const envExportsStr = envExports.join(' && '); + const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && '); const baseCmd = buildSpawnCommand({ mode, @@ -476,8 +504,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // For OpenCode: set sensitive env vars and config via tmux setenv // (not visible in ps output or tmux history, inherited by panes) if (mode === 'opencode') { - setOpenCodeEnvVars(muxName); - setOpenCodeConfigContent(muxName, openCodeConfig); + this._configureOpenCode(muxName, openCodeConfig); } // Replace the shell with the actual command (no echo in terminal) @@ -628,26 +655,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null; // Resolve CLI binary directory based on mode - let pathExport = ''; - if (mode === 'claude') { - const claudeDir = findClaudeDir(); - pathExport = claudeDir ? `export PATH="${claudeDir}:$PATH" && ` : ''; - } else if (mode === 'opencode') { - const openCodeDir = resolveOpenCodeDir(); - pathExport = openCodeDir ? `export PATH="${openCodeDir}:$PATH" && ` : ''; - } + const { pathExport } = this.buildPathExport(mode); - const envExports = [ - 'export LANG=en_US.UTF-8', - 'export LC_ALL=en_US.UTF-8', - 'unset COLORTERM', - 'export CODEMAN_MUX=1', - `export CODEMAN_SESSION_ID=${sessionId}`, - `export CODEMAN_MUX_NAME=${muxName}`, - `export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`, - ]; - if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE'); - const envExportsStr = envExports.join(' && '); + const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && '); const baseCmd = buildSpawnCommand({ mode, @@ -665,8 +675,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { try { // For OpenCode: set sensitive env vars via tmux setenv before respawn if (mode === 'opencode') { - setOpenCodeEnvVars(muxName); - setOpenCodeConfigContent(muxName, openCodeConfig); + this._configureOpenCode(muxName, openCodeConfig); } await execAsync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, { diff --git a/src/web/public/app.js b/src/web/public/app.js index 4c555f24..27013e81 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -432,6 +432,25 @@ class CodemanApp { return this._elemCache[id]; } + // Clear a named timeout property: if (this[name]) { clearTimeout(this[name]); this[name] = null; } + _clearTimer(timerName) { + if (this[timerName]) { + clearTimeout(this[timerName]); + this[timerName] = null; + } + } + + // Check if a selectSession generation is stale (a newer tab switch has started). + // If stale, cleans up buffer-loading state and returns true. + _isStaleSelect(selectGen) { + if (selectGen !== this._selectGeneration) { + if (this._isLoadingBuffer) this._finishBufferLoad(); + this._restoringFlushedState = false; + return true; + } + return false; + } + // Format token count: 1000k -> 1m, 1450k -> 1.45m, 500 -> 500 formatTokens(count) { if (count >= 1000000) { @@ -588,73 +607,43 @@ class CodemanApp { // ═══════════════════════════════════════════════════════════════ setupEventListeners() { + // Keyboard shortcut lookup table — data-driven to avoid 12 separate if-blocks. + // Each entry: { key, altKey? (alternative key match), ctrl? (require Ctrl/Cmd), + // shift? (require Shift), action }. + const SHORTCUTS = [ + { key: '?', altKey: '/', ctrl: true, action: () => this.showHelp() }, + { key: 'Enter', ctrl: true, action: () => this.quickStart() }, + { key: 'w', ctrl: true, action: () => this.killActiveSession() }, + { key: 'Tab', ctrl: true, action: () => this.nextSession() }, + { key: 'k', ctrl: true, action: () => this.killAllSessions() }, + { key: 'l', ctrl: true, action: () => this.clearTerminal() }, + { key: 'R', ctrl: true, shift: true, action: () => this.restoreTerminalSize() }, + { key: '=', altKey: '+', ctrl: true, action: () => this.increaseFontSize() }, + { key: '-', ctrl: true, action: () => this.decreaseFontSize() }, + { key: 'V', ctrl: true, shift: true, action: () => VoiceInput.toggle() }, + ]; + // Use capture to handle before terminal document.addEventListener('keydown', (e) => { // Don't intercept keys during CJK IME composition if (e.isComposing || e.keyCode === 229) return; - // Escape - close panels and modals + // Escape - close panels and modals (different logic: no preventDefault, no return) if (e.key === 'Escape') { this.closeAllPanels(); this.closeHelp(); } - // Ctrl/Cmd + ? - help - if ((e.ctrlKey || e.metaKey) && (e.key === '?' || e.key === '/')) { - e.preventDefault(); - this.showHelp(); - } - - // Ctrl/Cmd + Enter - quick start - if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { - e.preventDefault(); - this.quickStart(); - } - - // Ctrl/Cmd + W - close active session - if ((e.ctrlKey || e.metaKey) && e.key === 'w') { - e.preventDefault(); - this.killActiveSession(); - } - - // Ctrl/Cmd + Tab - next session - if ((e.ctrlKey || e.metaKey) && e.key === 'Tab') { - e.preventDefault(); - this.nextSession(); - } - - // Ctrl/Cmd + K - kill all - if ((e.ctrlKey || e.metaKey) && e.key === 'k') { - e.preventDefault(); - this.killAllSessions(); - } - - // Ctrl/Cmd + L - clear terminal - if ((e.ctrlKey || e.metaKey) && e.key === 'l') { - e.preventDefault(); - this.clearTerminal(); - } - - // Ctrl/Cmd + Shift + R - restore terminal size (after mobile squeeze) - if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'R') { - e.preventDefault(); - this.restoreTerminalSize(); - } - - // Ctrl/Cmd + +/- - font size - if ((e.ctrlKey || e.metaKey) && (e.key === '=' || e.key === '+')) { - e.preventDefault(); - this.increaseFontSize(); - } - if ((e.ctrlKey || e.metaKey) && e.key === '-') { - e.preventDefault(); - this.decreaseFontSize(); - } - - // Ctrl/Cmd + Shift + V - toggle voice input - if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'V') { - e.preventDefault(); - VoiceInput.toggle(); + // Match against shortcut table + for (const s of SHORTCUTS) { + const keyMatch = e.key === s.key || (s.altKey && e.key === s.altKey); + const ctrlMatch = s.ctrl ? (e.ctrlKey || e.metaKey) : true; + const shiftMatch = s.shift ? e.shiftKey : !e.shiftKey; + if (keyMatch && ctrlMatch && shiftMatch) { + e.preventDefault(); + s.action(); + return; + } } }, true); // Use capture phase to handle before terminal @@ -682,10 +671,7 @@ class CodemanApp { } // Clear any pending reconnect timeout to prevent duplicate connections - if (this.sseReconnectTimeout) { - clearTimeout(this.sseReconnectTimeout); - this.sseReconnectTimeout = null; - } + this._clearTimer('sseReconnectTimeout'); // Clean up existing SSE listeners before creating new connection (prevents listener accumulation) if (this._sseListenerCleanup) { @@ -742,9 +728,7 @@ class CodemanApp { this.eventSource = null; } // Clear any existing reconnect timeout before setting new one (prevents orphaned timeouts) - if (this.sseReconnectTimeout) { - clearTimeout(this.sseReconnectTimeout); - } + this._clearTimer('sseReconnectTimeout'); // Exponential backoff: 200ms, 500ms, 1s, 2s, 4s, ... up to 30s // Fast first retry (200ms) for server-restart case (COM deploy), // then ramp up for real network issues. @@ -1168,10 +1152,7 @@ class CodemanApp { /** Close the active WebSocket connection (if any). */ _disconnectWs() { - if (this._wsReconnectTimer) { - clearTimeout(this._wsReconnectTimer); - this._wsReconnectTimer = null; - } + this._clearTimer('_wsReconnectTimer'); this._wsReconnectAttempts = 0; if (this._ws) { this._ws.onclose = null; // Prevent re-entrant cleanup @@ -1317,35 +1298,12 @@ class CodemanApp { if (!showCjk) window.cjkActive = false; } - handleInit(data) { - // Clear the init fallback timer since we got data - if (this._initFallbackTimer) { - clearTimeout(this._initFallbackTimer); - this._initFallbackTimer = null; - } - const gen = ++this._initGeneration; - - // CJK input form: controlled by user setting (with server env as override) - this._serverCjkOverride = data.inputCjkForm || false; - this._updateCjkInputState(); - - // Update version displays (header and toolbar) - if (data.version) { - const versionEl = this.$('versionDisplay'); - const headerVersionEl = this.$('headerVersion'); - if (versionEl) { - versionEl.textContent = `v${data.version}`; - versionEl.title = `Codeman v${data.version}`; - } - if (headerVersionEl) { - headerVersionEl.textContent = `v${data.version}`; - headerVersionEl.title = `Codeman v${data.version}`; - } - } - - // Stop any active voice recording on reconnect - VoiceInput.cleanup(); - + /** + * Reset all app state maps, timers, and handlers to a clean baseline. + * Called by handleInit() on SSE reconnect / page reload to prevent + * memory leaks and stale data. + */ + _resetAllAppState() { this.sessions.clear(); this.ralphStates.clear(); this.terminalBuffers.clear(); @@ -1359,17 +1317,11 @@ class CodemanApp { } this.idleTimers.clear(); // Clear flicker filter state - if (this.flickerFilterTimeout) { - clearTimeout(this.flickerFilterTimeout); - this.flickerFilterTimeout = null; - } + this._clearTimer('flickerFilterTimeout'); this.flickerFilterBuffer = ''; this.flickerFilterActive = false; // Clear pending terminal writes - if (this.syncWaitTimeout) { - clearTimeout(this.syncWaitTimeout); - this.syncWaitTimeout = null; - } + this._clearTimer('syncWaitTimeout'); this.pendingWrites = []; this.writeFrameScheduled = false; this._isLoadingBuffer = false; @@ -1428,6 +1380,36 @@ class CodemanApp { clearInterval(this.runSummaryAutoRefreshTimer); this.runSummaryAutoRefreshTimer = null; } + } + + handleInit(data) { + // Clear the init fallback timer since we got data + this._clearTimer('_initFallbackTimer'); + const gen = ++this._initGeneration; + + // CJK input form: controlled by user setting (with server env as override) + this._serverCjkOverride = data.inputCjkForm || false; + this._updateCjkInputState(); + + // Update version displays (header and toolbar) + if (data.version) { + const versionEl = this.$('versionDisplay'); + const headerVersionEl = this.$('headerVersion'); + if (versionEl) { + versionEl.textContent = `v${data.version}`; + versionEl.title = `Codeman v${data.version}`; + } + if (headerVersionEl) { + headerVersionEl.textContent = `v${data.version}`; + headerVersionEl.title = `Codeman v${data.version}`; + } + } + + // Stop any active voice recording on reconnect + VoiceInput.cleanup(); + + this._resetAllAppState(); + data.sessions.forEach(s => { this.sessions.set(s.id, s); // Load ralph state from session data (only if not explicitly closed by user) @@ -2001,23 +1983,13 @@ class CodemanApp { return this.getShortId(session.id); } - async selectSession(sessionId) { - if (this.activeSessionId === sessionId) return; - // Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors - // programmatic focus() within the user-gesture call stack (e.g. tab click). - // After the first await the gesture context is lost and focus() is silently - // ignored, leaving the keyboard unable to send input to the terminal. - if (this.terminal) this.terminal.focus(); - - const _selStart = performance.now(); - const _selName = this.sessions.get(sessionId)?.name || sessionId.slice(0,8); - _crashDiag.log(`SELECT: ${_selName}`); - console.log(`[CRASH-DIAG] selectSession START: ${sessionId.slice(0,8)}`); - - const selectGen = ++this._selectGeneration; - - if (selectGen !== this._selectGeneration) return; // newer tab switch won - + /** + * Clean up state from the previous session before switching tabs. + * Handles: WebSocket teardown, CJK clear, flicker filter, tab completion, + * terminal write queue, IME composition, and local echo flush. + * @param {string} newSessionId - The session being switched TO. + */ + _cleanupPreviousSession(newSessionId) { // Close WebSocket for previous session (new one opens after buffer load) this._disconnectWs(); @@ -2026,10 +1998,7 @@ class CodemanApp { if (cjkEl) cjkEl.value = ''; // Clean up flicker filter state when switching sessions - if (this.flickerFilterTimeout) { - clearTimeout(this.flickerFilterTimeout); - this.flickerFilterTimeout = null; - } + this._clearTimer('flickerFilterTimeout'); this.flickerFilterBuffer = ''; this.flickerFilterActive = false; @@ -2037,14 +2006,11 @@ class CodemanApp { this._tabCompletionSessionId = null; this._tabCompletionRetries = 0; this._tabCompletionBaseText = null; - if (this._tabCompletionFallback) { clearTimeout(this._tabCompletionFallback); this._tabCompletionFallback = null; } - if (this._clientDropRecoveryTimer) { clearTimeout(this._clientDropRecoveryTimer); this._clientDropRecoveryTimer = null; } + this._clearTimer('_tabCompletionFallback'); + this._clearTimer('_clientDropRecoveryTimer'); // Clean up pending terminal writes to prevent old session data from appearing in new session - if (this.syncWaitTimeout) { - clearTimeout(this.syncWaitTimeout); - this.syncWaitTimeout = null; - } + this._clearTimer('syncWaitTimeout'); this.pendingWrites = []; this.writeFrameScheduled = false; this._isLoadingBuffer = false; @@ -2094,9 +2060,29 @@ class CodemanApp { // Only sessions with prior flushed text (from tab-switch-away) need detection. // After the user's first Enter, clear() resets _bufferDetectDone = false, // re-enabling detection for tab completion and other legitimate cases. - if (this._localEchoOverlay && !this._flushedOffsets?.has(sessionId)) { + if (this._localEchoOverlay && !this._flushedOffsets?.has(newSessionId)) { this._localEchoOverlay.suppressBufferDetection(); } + } + + async selectSession(sessionId) { + if (this.activeSessionId === sessionId) return; + // Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors + // programmatic focus() within the user-gesture call stack (e.g. tab click). + // After the first await the gesture context is lost and focus() is silently + // ignored, leaving the keyboard unable to send input to the terminal. + if (this.terminal) this.terminal.focus(); + + const _selStart = performance.now(); + const _selName = this.sessions.get(sessionId)?.name || sessionId.slice(0,8); + _crashDiag.log(`SELECT: ${_selName}`); + console.log(`[CRASH-DIAG] selectSession START: ${sessionId.slice(0,8)}`); + + const selectGen = ++this._selectGeneration; + + if (selectGen !== this._selectGeneration) return; // newer tab switch won + + this._cleanupPreviousSession(sessionId); this.activeSessionId = sessionId; try { localStorage.setItem('codeman-active-session', sessionId); } catch {} this.hideWelcome(); @@ -2186,7 +2172,7 @@ class CodemanApp { this.terminal.clear(); this.terminal.reset(); await this.chunkedTerminalWrite(cachedBuffer); - if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; } + if (this._isStaleSelect(selectGen)) return; this.terminal.scrollToBottom(); _crashDiag.log('CACHE_DONE'); } else if (sessionIsBusy) { @@ -2198,7 +2184,7 @@ class CodemanApp { _crashDiag.log('FETCH_START'); const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`); - if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; } + if (this._isStaleSelect(selectGen)) return; const data = await res.json(); _crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`); @@ -2217,7 +2203,7 @@ class CodemanApp { } // Use chunked write for large buffers to avoid UI jank await this.chunkedTerminalWrite(data.terminalBuffer); - if (selectGen !== this._selectGeneration) { if (this._isLoadingBuffer) this._finishBufferLoad(); this._restoringFlushedState = false; return; } + if (this._isStaleSelect(selectGen)) return; // Ensure terminal is scrolled to bottom after buffer load this.terminal.scrollToBottom(); } @@ -2624,9 +2610,7 @@ class CodemanApp { updateTokens() { // Debounce at 200ms — token display is non-critical and shouldn't // compete with input handling on the main thread - if (this._updateTokensTimeout) { - clearTimeout(this._updateTokensTimeout); - } + this._clearTimer('_updateTokensTimeout'); this._updateTokensTimeout = setTimeout(() => { this._updateTokensTimeout = null; this._updateTokensImmediate(); diff --git a/src/web/route-helpers.ts b/src/web/route-helpers.ts index b00c2c7c..ad79b803 100644 --- a/src/web/route-helpers.ts +++ b/src/web/route-helpers.ts @@ -6,6 +6,8 @@ */ import { join, resolve, relative, isAbsolute } from 'node:path'; +import { realpathSync } from 'node:fs'; +import fs from 'node:fs/promises'; import { homedir } from 'node:os'; import type { z } from 'zod'; import { Session } from '../session.js'; @@ -33,6 +35,45 @@ export function validatePathWithinBase(name: string, baseDir: string): string | return fullPath; } +/** + * Reads and parses a JSON config file, returning a default value on ENOENT. + * Logs an error for any I/O failure other than a missing file. + */ +export async function readJsonConfig(filePath: string, logLabel: string, defaultValue: T): Promise { + try { + const content = await fs.readFile(filePath, 'utf-8'); + return JSON.parse(content) as T; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error(`Failed to read ${logLabel}:`, err); + } + return defaultValue; + } +} + +/** + * Validates that a file path (possibly containing symlinks) resolves to a location + * within the given session working directory. Returns the resolved and relative paths, + * or null if the path escapes the directory or doesn't exist. + */ +export function validateSessionFilePath( + sessionWorkingDir: string, + filePath: string +): { resolvedPath: string; relativePath: string } | null { + const fullPath = resolve(sessionWorkingDir, filePath); + let resolvedPath: string; + try { + resolvedPath = realpathSync(fullPath); + } catch { + return null; + } + const relativePath = relative(sessionWorkingDir, resolvedPath); + if (relativePath.startsWith('..') || isAbsolute(relativePath)) { + return null; + } + return { resolvedPath, relativePath }; +} + // Maximum hook data size (prevents oversized SSE broadcasts) const MAX_HOOK_DATA_SIZE = 8 * 1024; diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 0d375c9e..efe0d9f8 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -14,7 +14,7 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types. import { CreateCaseSchema, LinkCaseSchema } from '../schemas.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; import { writeHooksConfig } from '../../hooks-config.js'; -import { CASES_DIR, validatePathWithinBase, parseBody } from '../route-helpers.js'; +import { CASES_DIR, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js'; import { SseEvent } from '../sse-events.js'; import type { EventPort, ConfigPort } from '../ports/index.js'; @@ -22,15 +22,7 @@ const LINKED_CASES_FILE = join(homedir(), '.codeman', 'linked-cases.json'); /** Read and parse linked-cases.json, returning empty object on missing/invalid file. */ async function readLinkedCases(): Promise> { - try { - return JSON.parse(await fs.readFile(LINKED_CASES_FILE, 'utf-8')); - } catch (err) { - // Only warn on real I/O errors, not ENOENT (file missing) or SyntaxError (corrupted JSON) - if ((err as NodeJS.ErrnoException).code && (err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.warn('[Server] Failed to read linked cases:', err); - } - return {}; - } + return readJsonConfig>(LINKED_CASES_FILE, 'linked cases', {}); } /** Resolve a case name to its directory path, checking linked cases first, then CASES_DIR. */ diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index 573a00da..00ee8c70 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -4,12 +4,11 @@ */ import { FastifyInstance } from 'fastify'; -import { join, resolve, relative, isAbsolute } from 'node:path'; -import { realpathSync } from 'node:fs'; +import { join } from 'node:path'; import fs from 'node:fs/promises'; import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; import { fileStreamManager } from '../../file-stream-manager.js'; -import { findSessionOrFail } from '../route-helpers.js'; +import { findSessionOrFail, validateSessionFilePath } from '../route-helpers.js'; import type { SessionPort } from '../ports/index.js'; export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void { @@ -148,17 +147,11 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void } // Validate path is within working directory (security: resolve symlinks to prevent traversal) - const fullPath = resolve(session.workingDir, filePath); - let resolvedPath: string; - try { - resolvedPath = realpathSync(fullPath); - } catch { + const validated = validateSessionFilePath(session.workingDir, filePath); + if (!validated) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'); } - const relativePath = relative(session.workingDir, resolvedPath); - if (relativePath.startsWith('..') || isAbsolute(relativePath)) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory'); - } + const { resolvedPath } = validated; try { const stat = await fs.stat(resolvedPath); @@ -255,19 +248,12 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void } // Validate path is within working directory (security: resolve symlinks to prevent traversal) - const fullPath = resolve(session.workingDir, filePath); - let resolvedPath: string; - try { - resolvedPath = realpathSync(fullPath); - } catch { + const validated = validateSessionFilePath(session.workingDir, filePath); + if (!validated) { reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found')); return; } - const relativePath = relative(session.workingDir, resolvedPath); - if (relativePath.startsWith('..') || isAbsolute(relativePath)) { - reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory')); - return; - } + const { resolvedPath } = validated; try { // Validate file size before reading (DoS protection - prevent memory exhaustion) diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 4005e845..d6e99d70 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -24,7 +24,7 @@ import { import { subagentWatcher } from '../../subagent-watcher.js'; import { imageWatcher } from '../../image-watcher.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js'; -import { findSessionOrFail, formatUptime, parseBody, SETTINGS_PATH } from '../route-helpers.js'; +import { findSessionOrFail, formatUptime, parseBody, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js'; import { SseEvent } from '../sse-events.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; @@ -393,15 +393,7 @@ export function registerSystemRoutes( // ========== Settings ========== app.get('/api/settings', async () => { - try { - const content = await fs.readFile(SETTINGS_PATH, 'utf-8'); - return JSON.parse(content); - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error('Failed to read settings:', err); - } - } - return {}; + return readJsonConfig(SETTINGS_PATH, 'settings', {}); }); app.put('/api/settings', async (req) => { @@ -472,16 +464,8 @@ export function registerSystemRoutes( // ========== Model Configuration ========== app.get('/api/execution/model-config', async () => { - try { - const content = await fs.readFile(SETTINGS_PATH, 'utf-8'); - const settings = JSON.parse(content); - return { success: true, data: settings.modelConfig || {} }; - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error('Failed to read model config:', err); - } - return { success: true, data: {} }; - } + const settings = await readJsonConfig>(SETTINGS_PATH, 'model config', {}); + return { success: true, data: settings.modelConfig || {} }; }); app.put('/api/execution/model-config', async (req) => { @@ -545,15 +529,7 @@ export function registerSystemRoutes( // ========== Subagent Window State Persistence ========== app.get('/api/subagent-window-states', async () => { - try { - const content = await fs.readFile(windowStatesPath, 'utf-8'); - return JSON.parse(content); - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error('Failed to read subagent window states:', err); - } - } - return { minimized: {}, open: [] }; + return readJsonConfig(windowStatesPath, 'subagent window states', { minimized: {}, open: [] }); }); app.put('/api/subagent-window-states', async (req) => { @@ -573,15 +549,7 @@ export function registerSystemRoutes( // ========== Subagent Parent Associations ========== app.get('/api/subagent-parents', async () => { - try { - const content = await fs.readFile(parentMapPath, 'utf-8'); - return JSON.parse(content); - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error('Failed to read subagent parent map:', err); - } - } - return {}; + return readJsonConfig(parentMapPath, 'subagent parent map', {}); }); app.put('/api/subagent-parents', async (req) => {