diff --git a/CHANGELOG.md b/CHANGELOG.md index 3498a78f..5751d8f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # aicodeman +## 0.3.9 + +### Patch Changes + +- Add content-hash cache busting for static assets — build step now renames JS/CSS files with MD5 content hashes (e.g. app.js → app.94b71235.js) and rewrites index.html references. HTML served with Cache-Control: no-cache so browsers always revalidate and pick up new hashed filenames after deploys. Hashed assets keep immutable 1-year cache. Eliminates the need for manual hard refresh (Ctrl+Shift+R) after deployments. + + Refactor path traversal validation into shared validatePathWithinBase() helper in route-helpers.ts, replacing 6 duplicate inline checks across case-routes, plan-routes, and session-routes. + + Deduplicate stripAnsi in bash-tool-parser.ts — use shared utility from utils/index.ts instead of private method. + ## 0.3.8 ### Patch Changes diff --git a/CLAUDE.md b/CLAUDE.md index b98628ae..67207bd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ When user says "COM": "aicodeman": patch --- - Description of changes + Detailed description of ALL changes since last release (not just the most recent commit — review full git log since last version tag) CHANGESET ``` Replace `patch` with `minor` or `major` as needed. Include `"xterm-zerolag-input": patch` on a separate line if that package changed too. @@ -52,7 +52,7 @@ When user says "COM": 4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json` 5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web` -**Version**: 0.3.8 (must match `package.json`) +**Version**: 0.3.9 (must match `package.json`) ## Project Overview diff --git a/package.json b/package.json index 0d949045..083df90b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicodeman", - "version": "0.3.8", + "version": "0.3.9", "description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/scripts/build.mjs b/scripts/build.mjs index ada6a2f2..f44095e4 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -8,13 +8,15 @@ * 2. Copy static assets (web/public, templates) * 3. Build vendor xterm bundles * 4. Minify frontend assets (app.js, styles.css, mobile.css) - * 5. Compress with gzip + brotli + * 5. Content-hash cache busting (rename assets, rewrite index.html) + * 6. Compress with gzip + brotli */ import { execSync } from 'child_process'; -import { appendFileSync } from 'fs'; +import { appendFileSync, readFileSync, writeFileSync, renameSync } from 'fs'; +import { createHash } from 'crypto'; import { fileURLToPath } from 'url'; -import { join } from 'path'; +import { join, extname, basename, dirname } from 'path'; const ROOT = join(fileURLToPath(import.meta.url), '..', '..'); @@ -27,7 +29,8 @@ function run(label, cmd) { run('tsc', 'tsc'); run('chmod dist/index.js', 'chmod +x dist/index.js'); -// 2. Copy static assets +// 2. Copy static assets (clean first to remove stale hashed files from previous builds) +run('clean public', 'rm -rf dist/web/public'); run('prepare dirs', 'mkdir -p dist/web dist/templates dist/web/public/vendor'); run('copy web assets', 'cp -r src/web/public dist/web/'); run('copy template', 'cp src/templates/case-template.md dist/templates/'); @@ -60,7 +63,49 @@ run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist run('minify styles.css', 'npx esbuild dist/web/public/styles.css --minify --outfile=dist/web/public/styles.css --allow-overwrite'); run('minify mobile.css', 'npx esbuild dist/web/public/mobile.css --minify --outfile=dist/web/public/mobile.css --allow-overwrite'); -// 5. Compress with gzip + brotli +// 5. Content-hash cache busting +console.log('\n[build] content-hash cache busting'); +{ + const distPublic = join(ROOT, 'dist/web/public'); + const HASHABLE = [ + 'styles.css', + 'mobile.css', + 'constants.js', + 'mobile-handlers.js', + 'voice-input.js', + 'notification-manager.js', + 'keyboard-accessory.js', + 'app.js', + 'ralph-wizard.js', + 'api-client.js', + 'subagent-windows.js', + 'vendor/xterm-zerolag-input.js', + ]; + const manifest = {}; + for (const file of HASHABLE) { + const filePath = join(distPublic, file); + const content = readFileSync(filePath); + const hash = createHash('md5').update(content).digest('hex').slice(0, 8); + const ext = extname(file); + const base = basename(file, ext); + const dir = dirname(file); + const hashed = dir === '.' ? `${base}.${hash}${ext}` : `${dir}/${base}.${hash}${ext}`; + renameSync(filePath, join(distPublic, hashed)); + manifest[file] = hashed; + } + // Rewrite index.html to reference hashed filenames + let html = readFileSync(join(distPublic, 'index.html'), 'utf8'); + for (const [original, hashed] of Object.entries(manifest)) { + html = html.replaceAll(`"${original}"`, `"${hashed}"`); + } + writeFileSync(join(distPublic, 'index.html'), html); + console.log(' Hashed files:'); + for (const [orig, hashed] of Object.entries(manifest)) { + console.log(` ${orig} -> ${hashed}`); + } +} + +// 6. Compress with gzip + brotli run( 'compress', `for f in dist/web/public/*.js dist/web/public/*.css dist/web/public/*.html dist/web/public/vendor/*.js dist/web/public/vendor/*.css; do` + diff --git a/src/bash-tool-parser.ts b/src/bash-tool-parser.ts index cb72435c..7f7a4a11 100644 --- a/src/bash-tool-parser.ts +++ b/src/bash-tool-parser.ts @@ -15,7 +15,7 @@ import { EventEmitter } from 'node:events'; import { v4 as uuidv4 } from 'uuid'; import { ActiveBashTool } from './types.js'; -import { CleanupManager, Debouncer } from './utils/index.js'; +import { CleanupManager, Debouncer, stripAnsi } from './utils/index.js'; // ========== Configuration Constants ========== @@ -462,7 +462,7 @@ export class BashToolParser extends EventEmitter { * Process a single line of terminal output (raw — will strip ANSI). */ private processLine(line: string): void { - const cleanLine = this.stripAnsi(line); + const cleanLine = stripAnsi(line); this.processCleanLine(cleanLine); } @@ -668,15 +668,6 @@ export class BashToolParser extends EventEmitter { return this.deduplicatePaths(rawPaths); } - /** - * Strip ANSI escape codes from a string. - */ - private stripAnsi(str: string): string { - // Comprehensive ANSI pattern - // eslint-disable-next-line no-control-regex - return str.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g, ''); - } - /** * Schedule a debounced update emission. */ diff --git a/src/web/public/index.html b/src/web/public/index.html index 2e28afa2..8e49a918 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -9,8 +9,8 @@ Codeman - - + + @@ -20,7 +20,7 @@ - + @@ -1677,14 +1677,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/src/web/route-helpers.ts b/src/web/route-helpers.ts index a4b236cf..3d5479ae 100644 --- a/src/web/route-helpers.ts +++ b/src/web/route-helpers.ts @@ -5,7 +5,7 @@ * that replaces ~43 inline not-found checks across route handlers. */ -import { join } from 'node:path'; +import { join, resolve, relative, isAbsolute } from 'node:path'; import { homedir } from 'node:os'; import { Session } from '../session.js'; import { ApiErrorCode, createErrorResponse } from '../types.js'; @@ -18,6 +18,20 @@ import type { EventPort } from './ports/event-port.js'; export const CASES_DIR = join(homedir(), 'codeman-cases'); export const SETTINGS_PATH = join(homedir(), '.codeman', 'settings.json'); +/** + * Validates that a path component doesn't escape the base directory. + * Returns the resolved full path, or null if the path is a traversal attempt. + */ +export function validatePathWithinBase(name: string, baseDir: string): string | null { + const fullPath = resolve(join(baseDir, name)); + const resolvedBase = resolve(baseDir); + const relPath = relative(resolvedBase, fullPath); + if (relPath.startsWith('..') || isAbsolute(relPath)) { + return null; + } + return fullPath; +} + // 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 8494a379..45d34fac 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -7,14 +7,14 @@ import { FastifyInstance } from 'fastify'; import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs'; import fs from 'node:fs/promises'; -import { join, resolve, relative, isAbsolute } from 'node:path'; +import { join, resolve } from 'node:path'; import { homedir } from 'node:os'; import type { ApiResponse, CaseInfo } from '../../types.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; 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 { CASES_DIR, validatePathWithinBase } from '../route-helpers.js'; import { SseEvent } from '../sse-events.js'; import type { EventPort, ConfigPort } from '../ports/index.js'; @@ -74,13 +74,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config } const { name, description } = result.data; - const casePath = join(CASES_DIR, name); - - // Security: Path traversal protection - use relative path check - const resolvedPath = resolve(casePath); - const resolvedBase = resolve(CASES_DIR); - const relPath = relative(resolvedBase, resolvedPath); - if (relPath.startsWith('..') || isAbsolute(relPath)) { + const casePath = validatePathWithinBase(name, CASES_DIR); + if (!casePath) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path'); } @@ -167,11 +162,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config app.get('/api/cases/:name', async (req) => { const { name } = req.params as { name: string }; - // Security: Path traversal protection - const resolvedPath = resolve(join(CASES_DIR, name)); - const resolvedBase = resolve(CASES_DIR); - const relPath = relative(resolvedBase, resolvedPath); - if (relPath.startsWith('..') || isAbsolute(relPath)) { + if (!validatePathWithinBase(name, CASES_DIR)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); } @@ -210,11 +201,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 }; - // Security: Path traversal protection - const resolvedPath = resolve(join(CASES_DIR, name)); - const resolvedBase = resolve(CASES_DIR); - const relPath = relative(resolvedBase, resolvedPath); - if (relPath.startsWith('..') || isAbsolute(relPath)) { + if (!validatePathWithinBase(name, CASES_DIR)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); } @@ -334,13 +321,8 @@ 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(CASES_DIR, caseName); - - // Security: Path traversal protection - use relative path check - const resolvedCase = resolve(casePath); - const resolvedBase = resolve(CASES_DIR); - const relPath = relative(resolvedBase, resolvedCase); - if (relPath.startsWith('..') || isAbsolute(relPath)) { + let casePath = validatePathWithinBase(caseName, CASES_DIR); + if (!casePath) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); } @@ -394,21 +376,16 @@ 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(CASES_DIR, caseName); + let casePath = validatePathWithinBase(caseName, CASES_DIR); + if (!casePath) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); + } // Prevent browser caching - prompts change between plan generations reply.header('Cache-Control', 'no-store, no-cache, must-revalidate'); reply.header('Pragma', 'no-cache'); reply.header('Expires', '0'); - // Security: Path traversal protection for case name - use relative path check - const resolvedCase = resolve(casePath); - const resolvedBase = resolve(CASES_DIR); - const relPath = relative(resolvedBase, resolvedCase); - if (relPath.startsWith('..') || isAbsolute(relPath)) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); - } - // Check linked cases if path doesn't exist if (!existsSync(casePath)) { const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json'); diff --git a/src/web/routes/plan-routes.ts b/src/web/routes/plan-routes.ts index c92e06d6..913ab557 100644 --- a/src/web/routes/plan-routes.ts +++ b/src/web/routes/plan-routes.ts @@ -5,7 +5,7 @@ */ import { FastifyInstance } from 'fastify'; -import { join, resolve, relative, isAbsolute } from 'node:path'; +import { join } from 'node:path'; import { existsSync, rmSync } from 'node:fs'; import { Session } from '../../session.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js'; @@ -17,7 +17,7 @@ import { PlanTaskUpdateSchema, PlanTaskAddSchema, } from '../schemas.js'; -import { findSessionOrFail, CASES_DIR } from '../route-helpers.js'; +import { findSessionOrFail, CASES_DIR, validatePathWithinBase } from '../route-helpers.js'; import { SseEvent } from '../sse-events.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js'; @@ -232,12 +232,8 @@ 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 casePath = join(CASES_DIR, caseName); - // Security: Path traversal protection - use relative path check - const resolvedCase = resolve(casePath); - const resolvedBase = resolve(CASES_DIR); - const relPath = relative(resolvedBase, resolvedCase); - if (!relPath.startsWith('..') && !isAbsolute(relPath) && existsSync(casePath)) { + const casePath = validatePathWithinBase(caseName, CASES_DIR); + if (casePath && existsSync(casePath)) { outputDir = join(casePath, 'ralph-wizard'); // Clear old ralph-wizard directory to ensure fresh prompts for each generation diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 1ff303c0..ea4c3fbb 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -5,7 +5,7 @@ */ import { FastifyInstance } from 'fastify'; -import { join, dirname, resolve, relative, isAbsolute } from 'node:path'; +import { join, dirname } from 'node:path'; import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs'; import fs from 'node:fs/promises'; import { @@ -32,7 +32,7 @@ import { QuickRunSchema, QuickStartSchema, } from '../schemas.js'; -import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH } from '../route-helpers.js'; +import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH, validatePathWithinBase } 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'; @@ -788,13 +788,8 @@ export function registerSessionRoutes( } } - const casePath = join(CASES_DIR, caseName); - - // Security: Path traversal protection - use relative path check - const resolvedPath = resolve(casePath); - const resolvedBase = resolve(CASES_DIR); - const relPath = relative(resolvedBase, resolvedPath); - if (relPath.startsWith('..') || isAbsolute(relPath)) { + const casePath = validatePathWithinBase(caseName, CASES_DIR); + if (!casePath) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path'); } diff --git a/src/web/server.ts b/src/web/server.ts index d8bd2f7b..b1b969c8 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -581,14 +581,22 @@ export class WebServer extends EventEmitter { .sendFile('sw.js', join(__dirname, 'public')); }); - // Serve static files — versioned assets (?v=X) are immutable, cache aggressively + // Serve static files — content-hashed assets (e.g. app.a3f8c2e1.js) are immutable, cache aggressively. + // HTML must revalidate every time so browsers pick up new hashed filenames after deploys. + // cacheControl disabled so setHeaders has full control (fastify-static's reply.headers() overwrites setHeaders otherwise). // preCompressed: serve pre-built .br/.gz files (from build step) to avoid per-request CPU compression await this.app.register(fastifyStatic, { root: join(__dirname, 'public'), prefix: '/', - maxAge: '1y', - immutable: true, + cacheControl: false, preCompressed: true, + setHeaders: (res, path) => { + if (path.endsWith('.html')) { + res.setHeader('Cache-Control', 'no-cache'); + } else { + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + } + }, }); // SSE endpoint for real-time updates diff --git a/tmp/desktop-toolbar.png b/tmp/desktop-toolbar.png new file mode 100644 index 00000000..d26ec2e4 Binary files /dev/null and b/tmp/desktop-toolbar.png differ diff --git a/tmp/ipad-mini-keyboard.png b/tmp/ipad-mini-keyboard.png new file mode 100644 index 00000000..8a689b1f Binary files /dev/null and b/tmp/ipad-mini-keyboard.png differ diff --git a/tmp/ipad-mini-normal.png b/tmp/ipad-mini-normal.png new file mode 100644 index 00000000..b39aa05e Binary files /dev/null and b/tmp/ipad-mini-normal.png differ diff --git a/tmp/ipad-mini-pr34-keyboard.png b/tmp/ipad-mini-pr34-keyboard.png new file mode 100644 index 00000000..41d45a9c Binary files /dev/null and b/tmp/ipad-mini-pr34-keyboard.png differ diff --git a/tmp/ipad-mini-pr34-normal.png b/tmp/ipad-mini-pr34-normal.png new file mode 100644 index 00000000..a34ca42e Binary files /dev/null and b/tmp/ipad-mini-pr34-normal.png differ diff --git a/tmp/ipad-mini-pr34-tabbar.png b/tmp/ipad-mini-pr34-tabbar.png new file mode 100644 index 00000000..1e4147e6 Binary files /dev/null and b/tmp/ipad-mini-pr34-tabbar.png differ diff --git a/tmp/ipad-mini-tabbar.png b/tmp/ipad-mini-tabbar.png new file mode 100644 index 00000000..3db06d24 Binary files /dev/null and b/tmp/ipad-mini-tabbar.png differ diff --git a/tmp/ipad-mini-toolbar.png b/tmp/ipad-mini-toolbar.png new file mode 100644 index 00000000..096271ef Binary files /dev/null and b/tmp/ipad-mini-toolbar.png differ