diff --git a/src/web/middleware/auth.ts b/src/web/middleware/auth.ts index 914d5032..2e5e3da0 100644 --- a/src/web/middleware/auth.ts +++ b/src/web/middleware/auth.ts @@ -15,7 +15,7 @@ import { StaleExpirationMap } from '../../utils/index.js'; // Auth session cookie TTL (24h — matches autonomous run length) const AUTH_SESSION_TTL_MS = 24 * 60 * 60 * 1000; // Auth session cookie name -const AUTH_COOKIE_NAME = 'codeman_session'; +export const AUTH_COOKIE_NAME = 'codeman_session'; // Max concurrent auth sessions const MAX_AUTH_SESSIONS = 100; // Max failed auth attempts per IP before rate-limiting diff --git a/src/web/ports/config-port.ts b/src/web/ports/config-port.ts index a8f2e640..9e8f347d 100644 --- a/src/web/ports/config-port.ts +++ b/src/web/ports/config-port.ts @@ -17,6 +17,7 @@ export interface ConfigPort { getClaudeModeConfig(): Promise<{ claudeMode?: ClaudeMode; allowedTools?: string }>; getDefaultClaudeMdPath(): Promise; getLightState(): unknown; + getLightSessionsState(): unknown[]; startTranscriptWatcher(sessionId: string, transcriptPath: string): void; stopTranscriptWatcher(sessionId: string): void; } diff --git a/src/web/ports/session-port.ts b/src/web/ports/session-port.ts index b0dc63ba..4fe3b96d 100644 --- a/src/web/ports/session-port.ts +++ b/src/web/ports/session-port.ts @@ -7,6 +7,7 @@ import type { Session } from '../../session.js'; export interface SessionPort { readonly sessions: ReadonlyMap; + addSession(session: Session): void; cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise; setupSessionListeners(session: Session): Promise; persistSessionState(session: Session): void; diff --git a/src/web/route-helpers.ts b/src/web/route-helpers.ts index 428038b7..33920b00 100644 --- a/src/web/route-helpers.ts +++ b/src/web/route-helpers.ts @@ -6,12 +6,17 @@ */ import { join } from 'node:path'; +import { homedir } from 'node:os'; import { Session } from '../session.js'; import { ApiErrorCode, createErrorResponse } from '../types.js'; import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js'; import type { SessionPort } from './ports/session-port.js'; import type { EventPort } from './ports/event-port.js'; +// Shared path constants used across route modules +export const CASES_DIR = join(homedir(), 'codeman-cases'); +export const SETTINGS_PATH = join(homedir(), '.codeman', 'settings.json'); + // 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 e6145a08..b461c576 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -14,30 +14,29 @@ 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 } from '../route-helpers.js'; import type { EventPort, ConfigPort } from '../ports/index.js'; -const casesDir = join(homedir(), 'codeman-cases'); - export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort): void { // ============ Case CRUD ============ app.get('/api/cases', async (): Promise => { const cases: CaseInfo[] = []; - // Get cases from casesDir + // Get cases from CASES_DIR try { - const entries = await fs.readdir(casesDir, { withFileTypes: true }); + const entries = await fs.readdir(CASES_DIR, { withFileTypes: true }); for (const e of entries) { if (e.isDirectory()) { cases.push({ name: e.name, - path: join(casesDir, e.name), - hasClaudeMd: existsSync(join(casesDir, e.name, 'CLAUDE.md')), + path: join(CASES_DIR, e.name), + hasClaudeMd: existsSync(join(CASES_DIR, e.name, 'CLAUDE.md')), }); } } } catch { - // casesDir may not exist yet + // CASES_DIR may not exist yet } // Get linked cases @@ -70,11 +69,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config } const { name, description } = result.data; - const casePath = join(casesDir, name); + const casePath = join(CASES_DIR, name); // Security: Path traversal protection - use relative path check const resolvedPath = resolve(casePath); - const resolvedBase = resolve(casesDir); + const resolvedBase = resolve(CASES_DIR); const relPath = relative(resolvedBase, resolvedPath); if (relPath.startsWith('..') || isAbsolute(relPath)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path'); @@ -120,8 +119,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${expandedPath}`); } - // Check if case name already exists in casesDir - const casePath = join(casesDir, name); + // Check if case name already exists in CASES_DIR + const casePath = join(CASES_DIR, name); if (existsSync(casePath)) { return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.'); } @@ -177,11 +176,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config }; } } catch { - // ENOENT or parse errors - fall through to casesDir check + // ENOENT or parse errors - fall through to CASES_DIR check } - // Then check casesDir - const casePath = join(casesDir, name); + // Then check CASES_DIR + const casePath = join(CASES_DIR, name); if (!existsSync(casePath)) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found'); @@ -198,7 +197,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config app.get('/api/cases/:name/fix-plan', async (req) => { const { name } = req.params as { name: string }; - // Get case path (check linked cases first, then casesDir) + // Get case path (check linked cases first, then CASES_DIR) let casePath: string | null = null; const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json'); @@ -208,11 +207,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config casePath = linkedCases[name]; } } catch { - // ENOENT or parse errors - fall through to casesDir + // ENOENT or parse errors - fall through to CASES_DIR } if (!casePath) { - casePath = join(casesDir, name); + casePath = join(CASES_DIR, name); } const fixPlanPath = join(casePath, '@fix_plan.md'); @@ -310,11 +309,11 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => { const { caseName } = req.params as { caseName: string }; - let casePath = join(casesDir, caseName); + let casePath = join(CASES_DIR, caseName); // Security: Path traversal protection - use relative path check const resolvedCase = resolve(casePath); - const resolvedBase = resolve(casesDir); + const resolvedBase = resolve(CASES_DIR); const relPath = relative(resolvedBase, resolvedCase); if (relPath.startsWith('..') || isAbsolute(relPath)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); @@ -370,7 +369,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config // Cache disabled to ensure fresh prompts when starting new plan generations app.get('/api/cases/:caseName/ralph-wizard/file/:filePath', async (req, reply) => { const { caseName, filePath } = req.params as { caseName: string; filePath: string }; - let casePath = join(casesDir, caseName); + let casePath = join(CASES_DIR, caseName); // Prevent browser caching - prompts change between plan generations reply.header('Cache-Control', 'no-store, no-cache, must-revalidate'); @@ -379,7 +378,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config // Security: Path traversal protection for case name - use relative path check const resolvedCase = resolve(casePath); - const resolvedBase = resolve(casesDir); + const resolvedBase = resolve(CASES_DIR); const relPath = relative(resolvedBase, resolvedCase); if (relPath.startsWith('..') || isAbsolute(relPath)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); diff --git a/src/web/routes/plan-routes.ts b/src/web/routes/plan-routes.ts index 2f7173dd..a5d99a40 100644 --- a/src/web/routes/plan-routes.ts +++ b/src/web/routes/plan-routes.ts @@ -7,7 +7,6 @@ import { FastifyInstance } from 'fastify'; import { join, resolve, relative, isAbsolute } from 'node:path'; import { existsSync, rmSync } from 'node:fs'; -import { homedir } from 'node:os'; import { Session } from '../../session.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js'; import { PlanOrchestrator, type PlanItem, type DetailedPlanResult } from '../../plan-orchestrator.js'; @@ -18,7 +17,7 @@ import { PlanTaskUpdateSchema, PlanTaskAddSchema, } from '../schemas.js'; -import { findSessionOrFail } from '../route-helpers.js'; +import { findSessionOrFail, CASES_DIR } from '../route-helpers.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js'; export function registerPlanRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort & InfraPort): void { @@ -228,11 +227,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Determine output directory for saving wizard results let outputDir: string | undefined; if (caseName) { - const casesDir = join(homedir(), 'codeman-cases'); - const casePath = join(casesDir, caseName); + const casePath = join(CASES_DIR, caseName); // Security: Path traversal protection - use relative path check const resolvedCase = resolve(casePath); - const resolvedBase = resolve(casesDir); + const resolvedBase = resolve(CASES_DIR); const relPath = relative(resolvedBase, resolvedCase); if (!relPath.startsWith('..') && !isAbsolute(relPath) && existsSync(casePath)) { outputDir = join(casePath, 'ralph-wizard'); diff --git a/src/web/routes/ralph-routes.ts b/src/web/routes/ralph-routes.ts index f809d0a6..2b9eecb2 100644 --- a/src/web/routes/ralph-routes.ts +++ b/src/web/routes/ralph-routes.ts @@ -8,21 +8,17 @@ import { FastifyInstance } from 'fastify'; import { join, dirname, resolve, relative, isAbsolute } from 'node:path'; import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import fs from 'node:fs/promises'; -import { homedir } from 'node:os'; import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js'; import { Session } from '../../session.js'; import { RespawnController } from '../../respawn-controller.js'; import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js'; -import { autoConfigureRalph } from '../route-helpers.js'; +import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH } from '../route-helpers.js'; import { writeHooksConfig } from '../../hooks-config.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js'; import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js'; import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js'; -const casesDir = join(homedir(), 'codeman-cases'); -const settingsPath = join(homedir(), '.codeman', 'settings.json'); - export function registerRalphRoutes( app: FastifyInstance, ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort @@ -305,11 +301,11 @@ export function registerRalphRoutes( } const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems } = rlResult.data; - const casePath = join(casesDir, caseName); + const casePath = join(CASES_DIR, caseName); // Security: Path traversal protection const rlResolvedPath = resolve(casePath); - const rlResolvedBase = resolve(casesDir); + const rlResolvedBase = resolve(CASES_DIR); const rlRelPath = relative(rlResolvedBase, rlResolvedPath); if (rlRelPath.startsWith('..') || isAbsolute(rlRelPath)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path'); @@ -435,7 +431,7 @@ export function registerRalphRoutes( writeFileSync(promptPath, fullPrompt, 'utf-8'); // Register session - (ctx.sessions as Map).set(session.id, session); + ctx.addSession(session); ctx.store.incrementSessionsCreated(); ctx.persistSessionState(session); await ctx.setupSessionListeners(session); @@ -489,14 +485,14 @@ export function registerRalphRoutes( try { let settings: Record = {}; try { - settings = JSON.parse(await fs.readFile(settingsPath, 'utf-8')); + settings = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8')); } catch { /* ignore */ } settings.lastUsedCase = caseName; - const dir = dirname(settingsPath); + const dir = dirname(SETTINGS_PATH); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)).catch(() => {}); + fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)).catch(() => {}); } catch { /* non-critical */ } diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 50ad86da..075e7baf 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -8,7 +8,6 @@ import { FastifyInstance } from 'fastify'; import { join, dirname, resolve, relative, isAbsolute } from 'node:path'; import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs'; import fs from 'node:fs/promises'; -import { homedir } from 'node:os'; import { ApiErrorCode, createErrorResponse, @@ -32,7 +31,8 @@ import { QuickRunSchema, QuickStartSchema, } from '../schemas.js'; -import { autoConfigureRalph } from '../route-helpers.js'; +import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH } from '../route-helpers.js'; +import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; import { writeHooksConfig, updateCaseEnvVars } from '../../hooks-config.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; import { imageWatcher } from '../../image-watcher.js'; @@ -46,7 +46,6 @@ const MAX_INPUT_LENGTH = 64 * 1024; const MAX_TERMINAL_COLS = 500; const MAX_TERMINAL_ROWS = 200; const MAX_SESSION_NAME_LENGTH = 128; -const AUTH_COOKIE_NAME = 'codeman_session'; // Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation) // eslint-disable-next-line no-control-regex @@ -55,9 +54,6 @@ const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/; const CTRL_L_PATTERN = /\x0c/g; const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/; -const casesDir = join(homedir(), 'codeman-cases'); -const settingsPath = join(homedir(), '.codeman', 'settings.json'); - export function registerSessionRoutes( app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort & InfraPort @@ -72,7 +68,7 @@ export function registerSessionRoutes( // ========== Session Listing ========== app.get('/api/sessions', async () => { - return Array.from(ctx.sessions.values()).map((s) => ctx.getSessionStateWithRespawn(s)); + return ctx.getLightSessionsState(); }); // ========== Session Creation ========== @@ -140,7 +136,7 @@ export function registerSessionRoutes( openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined, }); - (ctx.sessions as Map).set(session.id, session); + ctx.addSession(session); ctx.store.incrementSessionsCreated(); ctx.persistSessionState(session); await ctx.setupSessionListeners(session); @@ -717,7 +713,7 @@ export function registerSessionRoutes( } const session = new Session({ workingDir: dir }); - (ctx.sessions as Map).set(session.id, session); + ctx.addSession(session); ctx.store.incrementSessionsCreated(); ctx.persistSessionState(session); await ctx.setupSessionListeners(session); @@ -770,11 +766,11 @@ export function registerSessionRoutes( } } - const casePath = join(casesDir, caseName); + const casePath = join(CASES_DIR, caseName); // Security: Path traversal protection - use relative path check const resolvedPath = resolve(casePath); - const resolvedBase = resolve(casesDir); + const resolvedBase = resolve(CASES_DIR); const relPath = relative(resolvedBase, resolvedPath); if (relPath.startsWith('..') || isAbsolute(relPath)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path'); @@ -832,7 +828,7 @@ export function registerSessionRoutes( } } - (ctx.sessions as Map).set(session.id, session); + ctx.addSession(session); ctx.store.incrementSessionsCreated(); ctx.persistSessionState(session); await ctx.setupSessionListeners(session); @@ -870,7 +866,7 @@ export function registerSessionRoutes( // Save lastUsedCase to settings for TUI/web sync try { - const settingsFilePath = settingsPath; + const settingsFilePath = SETTINGS_PATH; let settings: Record = {}; try { settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8')); diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 2a48824b..a2a2975d 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -22,7 +22,7 @@ import { import { subagentWatcher } from '../../subagent-watcher.js'; import { imageWatcher } from '../../image-watcher.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js'; -import { findSessionOrFail, formatUptime } from '../route-helpers.js'; +import { findSessionOrFail, formatUptime, SETTINGS_PATH } from '../route-helpers.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js'; // Maximum screenshot upload size (10MB) @@ -83,7 +83,6 @@ export function registerSystemRoutes( app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort & InfraPort ): void { - const settingsPath = join(homedir(), '.codeman', 'settings.json'); const windowStatesPath = join(homedir(), '.codeman', 'subagent-window-states.json'); const parentMapPath = join(homedir(), '.codeman', 'subagent-parents.json'); @@ -101,6 +100,7 @@ export function registerSystemRoutes( return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Tunnel not running')); } try { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- dynamic optional dependency const QRCode = require('qrcode'); const svg: string = await QRCode.toString(url, { type: 'svg', margin: 2, width: 256 }); return { svg }; @@ -262,7 +262,7 @@ export function registerSystemRoutes( app.get('/api/settings', async () => { try { - const content = await fs.readFile(settingsPath, 'utf-8'); + const content = await fs.readFile(SETTINGS_PATH, 'utf-8'); return JSON.parse(content); } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { @@ -280,18 +280,18 @@ export function registerSystemRoutes( const settings = settingsResult.data as Record; try { - const dir = dirname(settingsPath); + const dir = dirname(SETTINGS_PATH); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } let existing: Record = {}; try { - existing = JSON.parse(await fs.readFile(settingsPath, 'utf-8')); + existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8')); } catch { /* ignore */ } const merged = { ...existing, ...settings }; - await fs.writeFile(settingsPath, JSON.stringify(merged, null, 2)); + await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2)); // Handle subagent tracking toggle dynamically const subagentEnabled = settings.subagentTrackingEnabled ?? true; @@ -345,7 +345,7 @@ export function registerSystemRoutes( app.get('/api/execution/model-config', async () => { try { - const content = await fs.readFile(settingsPath, 'utf-8'); + const content = await fs.readFile(SETTINGS_PATH, 'utf-8'); const settings = JSON.parse(content); return { success: true, data: settings.modelConfig || {} }; } catch (err) { @@ -366,7 +366,7 @@ export function registerSystemRoutes( try { let existingSettings: Record = {}; try { - const content = await fs.readFile(settingsPath, 'utf-8'); + const content = await fs.readFile(SETTINGS_PATH, 'utf-8'); existingSettings = JSON.parse(content); } catch { // File doesn't exist yet, start fresh @@ -374,11 +374,11 @@ export function registerSystemRoutes( existingSettings.modelConfig = modelConfig; - const dir = dirname(settingsPath); + const dir = dirname(SETTINGS_PATH); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - await fs.writeFile(settingsPath, JSON.stringify(existingSettings, null, 2)); + await fs.writeFile(SETTINGS_PATH, JSON.stringify(existingSettings, null, 2)); return { success: true }; } catch (err) { diff --git a/src/web/server.ts b/src/web/server.ts index 91465213..d006c2c5 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -440,6 +440,9 @@ export class WebServer extends EventEmitter { return { // SessionPort sessions: this.sessions as ReadonlyMap, + addSession: (session: Session) => { + this.sessions.set(session.id, session); + }, cleanupSession: this.cleanupSession.bind(this), setupSessionListeners: this.setupSessionListeners.bind(this), persistSessionState: this.persistSessionState.bind(this), @@ -469,6 +472,7 @@ export class WebServer extends EventEmitter { getClaudeModeConfig: this.getClaudeModeConfig.bind(this), getDefaultClaudeMdPath: this.getDefaultClaudeMdPath.bind(this), getLightState: this.getLightState.bind(this), + getLightSessionsState: this.getLightSessionsState.bind(this), startTranscriptWatcher: this.startTranscriptWatcher.bind(this), stopTranscriptWatcher: this.stopTranscriptWatcher.bind(this), // InfraPort