Skip to content

Commit a14e47e

Browse files
Ark0Nclaude
andcommitted
chore: version packages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent da85e97 commit a14e47e

19 files changed

+123
-87
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# aicodeman
22

3+
## 0.3.9
4+
5+
### Patch Changes
6+
7+
- 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.
8+
9+
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.
10+
11+
Deduplicate stripAnsi in bash-tool-parser.ts — use shared utility from utils/index.ts instead of private method.
12+
313
## 0.3.8
414

515
### Patch Changes

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,15 @@ When user says "COM":
4444
"aicodeman": patch
4545
---
4646
47-
Description of changes
47+
Detailed description of ALL changes since last release (not just the most recent commit — review full git log since last version tag)
4848
CHANGESET
4949
```
5050
Replace `patch` with `minor` or `major` as needed. Include `"xterm-zerolag-input": patch` on a separate line if that package changed too.
5151
3. **Consume the changeset**: `npm run version-packages` (bumps versions in `package.json` files and updates `CHANGELOG.md`)
5252
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
5353
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
5454
55-
**Version**: 0.3.8 (must match `package.json`)
55+
**Version**: 0.3.9 (must match `package.json`)
5656
5757
## Project Overview
5858

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "aicodeman",
3-
"version": "0.3.8",
3+
"version": "0.3.9",
44
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
55
"type": "module",
66
"main": "dist/index.js",

scripts/build.mjs

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
88
* 2. Copy static assets (web/public, templates)
99
* 3. Build vendor xterm bundles
1010
* 4. Minify frontend assets (app.js, styles.css, mobile.css)
11-
* 5. Compress with gzip + brotli
11+
* 5. Content-hash cache busting (rename assets, rewrite index.html)
12+
* 6. Compress with gzip + brotli
1213
*/
1314

1415
import { execSync } from 'child_process';
15-
import { appendFileSync } from 'fs';
16+
import { appendFileSync, readFileSync, writeFileSync, renameSync } from 'fs';
17+
import { createHash } from 'crypto';
1618
import { fileURLToPath } from 'url';
17-
import { join } from 'path';
19+
import { join, extname, basename, dirname } from 'path';
1820

1921
const ROOT = join(fileURLToPath(import.meta.url), '..', '..');
2022

@@ -27,7 +29,8 @@ function run(label, cmd) {
2729
run('tsc', 'tsc');
2830
run('chmod dist/index.js', 'chmod +x dist/index.js');
2931

30-
// 2. Copy static assets
32+
// 2. Copy static assets (clean first to remove stale hashed files from previous builds)
33+
run('clean public', 'rm -rf dist/web/public');
3134
run('prepare dirs', 'mkdir -p dist/web dist/templates dist/web/public/vendor');
3235
run('copy web assets', 'cp -r src/web/public dist/web/');
3336
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
6063
run('minify styles.css', 'npx esbuild dist/web/public/styles.css --minify --outfile=dist/web/public/styles.css --allow-overwrite');
6164
run('minify mobile.css', 'npx esbuild dist/web/public/mobile.css --minify --outfile=dist/web/public/mobile.css --allow-overwrite');
6265

63-
// 5. Compress with gzip + brotli
66+
// 5. Content-hash cache busting
67+
console.log('\n[build] content-hash cache busting');
68+
{
69+
const distPublic = join(ROOT, 'dist/web/public');
70+
const HASHABLE = [
71+
'styles.css',
72+
'mobile.css',
73+
'constants.js',
74+
'mobile-handlers.js',
75+
'voice-input.js',
76+
'notification-manager.js',
77+
'keyboard-accessory.js',
78+
'app.js',
79+
'ralph-wizard.js',
80+
'api-client.js',
81+
'subagent-windows.js',
82+
'vendor/xterm-zerolag-input.js',
83+
];
84+
const manifest = {};
85+
for (const file of HASHABLE) {
86+
const filePath = join(distPublic, file);
87+
const content = readFileSync(filePath);
88+
const hash = createHash('md5').update(content).digest('hex').slice(0, 8);
89+
const ext = extname(file);
90+
const base = basename(file, ext);
91+
const dir = dirname(file);
92+
const hashed = dir === '.' ? `${base}.${hash}${ext}` : `${dir}/${base}.${hash}${ext}`;
93+
renameSync(filePath, join(distPublic, hashed));
94+
manifest[file] = hashed;
95+
}
96+
// Rewrite index.html to reference hashed filenames
97+
let html = readFileSync(join(distPublic, 'index.html'), 'utf8');
98+
for (const [original, hashed] of Object.entries(manifest)) {
99+
html = html.replaceAll(`"${original}"`, `"${hashed}"`);
100+
}
101+
writeFileSync(join(distPublic, 'index.html'), html);
102+
console.log(' Hashed files:');
103+
for (const [orig, hashed] of Object.entries(manifest)) {
104+
console.log(` ${orig} -> ${hashed}`);
105+
}
106+
}
107+
108+
// 6. Compress with gzip + brotli
64109
run(
65110
'compress',
66111
`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` +

src/bash-tool-parser.ts

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import { EventEmitter } from 'node:events';
1616
import { v4 as uuidv4 } from 'uuid';
1717
import { ActiveBashTool } from './types.js';
18-
import { CleanupManager, Debouncer } from './utils/index.js';
18+
import { CleanupManager, Debouncer, stripAnsi } from './utils/index.js';
1919

2020
// ========== Configuration Constants ==========
2121

@@ -462,7 +462,7 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
462462
* Process a single line of terminal output (raw — will strip ANSI).
463463
*/
464464
private processLine(line: string): void {
465-
const cleanLine = this.stripAnsi(line);
465+
const cleanLine = stripAnsi(line);
466466
this.processCleanLine(cleanLine);
467467
}
468468

@@ -668,15 +668,6 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
668668
return this.deduplicatePaths(rawPaths);
669669
}
670670

671-
/**
672-
* Strip ANSI escape codes from a string.
673-
*/
674-
private stripAnsi(str: string): string {
675-
// Comprehensive ANSI pattern
676-
// eslint-disable-next-line no-control-regex
677-
return str.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g, '');
678-
}
679-
680671
/**
681672
* Schedule a debounced update emission.
682673
*/

src/web/public/index.html

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
<link rel="manifest" href="manifest.json">
1010
<title>Codeman</title>
1111
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0%25' y1='0%25' x2='100%25' y2='100%25'%3E%3Cstop offset='0%25' stop-color='%2360a5fa'/%3E%3Cstop offset='100%25' stop-color='%233b82f6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='6' fill='%230a0a0a'/%3E%3Cpath d='M18 4L8 18h6l-2 10 10-14h-6z' fill='url(%23g)'/%3E%3C/svg%3E">
12-
<link rel="stylesheet" href="styles.css?v=0.1633">
13-
<link rel="stylesheet" href="mobile.css?v=0.1633" media="(max-width: 1023px)">
12+
<link rel="stylesheet" href="styles.css">
13+
<link rel="stylesheet" href="mobile.css" media="(max-width: 1023px)">
1414
<!-- xterm.css loaded async — terminal won't display until xterm.js runs anyway -->
1515
<link rel="preload" href="vendor/xterm.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
1616
<noscript><link rel="stylesheet" href="vendor/xterm.css"></noscript>
@@ -20,7 +20,7 @@
2020
<script defer src="vendor/xterm-addon-fit.min.js"></script>
2121
<script defer src="vendor/xterm-addon-webgl.min.js"></script>
2222
<script defer src="vendor/xterm-addon-unicode11.min.js"></script>
23-
<script defer src="vendor/xterm-zerolag-input.js?v=0.3.2"></script>
23+
<script defer src="vendor/xterm-zerolag-input.js"></script>
2424
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
2525
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
2626
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
@@ -1677,14 +1677,14 @@ <h3>Save Respawn Preset</h3>
16771677
<!-- Lines drawn dynamically -->
16781678
</svg>
16791679

1680-
<script defer src="constants.js?v=0.3.2"></script>
1681-
<script defer src="mobile-handlers.js?v=0.3.2"></script>
1682-
<script defer src="voice-input.js?v=0.3.2"></script>
1683-
<script defer src="notification-manager.js?v=0.3.2"></script>
1684-
<script defer src="keyboard-accessory.js?v=0.3.2"></script>
1685-
<script defer src="app.js?v=0.3.2"></script>
1686-
<script defer src="ralph-wizard.js?v=0.3.2"></script>
1687-
<script defer src="api-client.js?v=0.3.2"></script>
1688-
<script defer src="subagent-windows.js?v=0.3.2"></script>
1680+
<script defer src="constants.js"></script>
1681+
<script defer src="mobile-handlers.js"></script>
1682+
<script defer src="voice-input.js"></script>
1683+
<script defer src="notification-manager.js"></script>
1684+
<script defer src="keyboard-accessory.js"></script>
1685+
<script defer src="app.js"></script>
1686+
<script defer src="ralph-wizard.js"></script>
1687+
<script defer src="api-client.js"></script>
1688+
<script defer src="subagent-windows.js"></script>
16891689
</body>
16901690
</html>

src/web/route-helpers.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* that replaces ~43 inline not-found checks across route handlers.
66
*/
77

8-
import { join } from 'node:path';
8+
import { join, resolve, relative, isAbsolute } from 'node:path';
99
import { homedir } from 'node:os';
1010
import { Session } from '../session.js';
1111
import { ApiErrorCode, createErrorResponse } from '../types.js';
@@ -18,6 +18,20 @@ import type { EventPort } from './ports/event-port.js';
1818
export const CASES_DIR = join(homedir(), 'codeman-cases');
1919
export const SETTINGS_PATH = join(homedir(), '.codeman', 'settings.json');
2020

21+
/**
22+
* Validates that a path component doesn't escape the base directory.
23+
* Returns the resolved full path, or null if the path is a traversal attempt.
24+
*/
25+
export function validatePathWithinBase(name: string, baseDir: string): string | null {
26+
const fullPath = resolve(join(baseDir, name));
27+
const resolvedBase = resolve(baseDir);
28+
const relPath = relative(resolvedBase, fullPath);
29+
if (relPath.startsWith('..') || isAbsolute(relPath)) {
30+
return null;
31+
}
32+
return fullPath;
33+
}
34+
2135
// Maximum hook data size (prevents oversized SSE broadcasts)
2236
const MAX_HOOK_DATA_SIZE = 8 * 1024;
2337

src/web/routes/case-routes.ts

Lines changed: 12 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@
77
import { FastifyInstance } from 'fastify';
88
import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs';
99
import fs from 'node:fs/promises';
10-
import { join, resolve, relative, isAbsolute } from 'node:path';
10+
import { join, resolve } from 'node:path';
1111
import { homedir } from 'node:os';
1212
import type { ApiResponse, CaseInfo } from '../../types.js';
1313
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
1414
import { CreateCaseSchema, LinkCaseSchema } from '../schemas.js';
1515
import { generateClaudeMd } from '../../templates/claude-md.js';
1616
import { writeHooksConfig } from '../../hooks-config.js';
17-
import { CASES_DIR } from '../route-helpers.js';
17+
import { CASES_DIR, validatePathWithinBase } from '../route-helpers.js';
1818
import { SseEvent } from '../sse-events.js';
1919
import type { EventPort, ConfigPort } from '../ports/index.js';
2020

@@ -74,13 +74,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
7474
}
7575
const { name, description } = result.data;
7676

77-
const casePath = join(CASES_DIR, name);
78-
79-
// Security: Path traversal protection - use relative path check
80-
const resolvedPath = resolve(casePath);
81-
const resolvedBase = resolve(CASES_DIR);
82-
const relPath = relative(resolvedBase, resolvedPath);
83-
if (relPath.startsWith('..') || isAbsolute(relPath)) {
77+
const casePath = validatePathWithinBase(name, CASES_DIR);
78+
if (!casePath) {
8479
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
8580
}
8681

@@ -167,11 +162,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
167162
app.get('/api/cases/:name', async (req) => {
168163
const { name } = req.params as { name: string };
169164

170-
// Security: Path traversal protection
171-
const resolvedPath = resolve(join(CASES_DIR, name));
172-
const resolvedBase = resolve(CASES_DIR);
173-
const relPath = relative(resolvedBase, resolvedPath);
174-
if (relPath.startsWith('..') || isAbsolute(relPath)) {
165+
if (!validatePathWithinBase(name, CASES_DIR)) {
175166
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
176167
}
177168

@@ -210,11 +201,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
210201
app.get('/api/cases/:name/fix-plan', async (req) => {
211202
const { name } = req.params as { name: string };
212203

213-
// Security: Path traversal protection
214-
const resolvedPath = resolve(join(CASES_DIR, name));
215-
const resolvedBase = resolve(CASES_DIR);
216-
const relPath = relative(resolvedBase, resolvedPath);
217-
if (relPath.startsWith('..') || isAbsolute(relPath)) {
204+
if (!validatePathWithinBase(name, CASES_DIR)) {
218205
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
219206
}
220207

@@ -334,13 +321,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
334321

335322
app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => {
336323
const { caseName } = req.params as { caseName: string };
337-
let casePath = join(CASES_DIR, caseName);
338-
339-
// Security: Path traversal protection - use relative path check
340-
const resolvedCase = resolve(casePath);
341-
const resolvedBase = resolve(CASES_DIR);
342-
const relPath = relative(resolvedBase, resolvedCase);
343-
if (relPath.startsWith('..') || isAbsolute(relPath)) {
324+
let casePath = validatePathWithinBase(caseName, CASES_DIR);
325+
if (!casePath) {
344326
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
345327
}
346328

@@ -394,21 +376,16 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
394376
// Cache disabled to ensure fresh prompts when starting new plan generations
395377
app.get('/api/cases/:caseName/ralph-wizard/file/:filePath', async (req, reply) => {
396378
const { caseName, filePath } = req.params as { caseName: string; filePath: string };
397-
let casePath = join(CASES_DIR, caseName);
379+
let casePath = validatePathWithinBase(caseName, CASES_DIR);
380+
if (!casePath) {
381+
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
382+
}
398383

399384
// Prevent browser caching - prompts change between plan generations
400385
reply.header('Cache-Control', 'no-store, no-cache, must-revalidate');
401386
reply.header('Pragma', 'no-cache');
402387
reply.header('Expires', '0');
403388

404-
// Security: Path traversal protection for case name - use relative path check
405-
const resolvedCase = resolve(casePath);
406-
const resolvedBase = resolve(CASES_DIR);
407-
const relPath = relative(resolvedBase, resolvedCase);
408-
if (relPath.startsWith('..') || isAbsolute(relPath)) {
409-
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
410-
}
411-
412389
// Check linked cases if path doesn't exist
413390
if (!existsSync(casePath)) {
414391
const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json');

src/web/routes/plan-routes.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
import { FastifyInstance } from 'fastify';
8-
import { join, resolve, relative, isAbsolute } from 'node:path';
8+
import { join } from 'node:path';
99
import { existsSync, rmSync } from 'node:fs';
1010
import { Session } from '../../session.js';
1111
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
@@ -17,7 +17,7 @@ import {
1717
PlanTaskUpdateSchema,
1818
PlanTaskAddSchema,
1919
} from '../schemas.js';
20-
import { findSessionOrFail, CASES_DIR } from '../route-helpers.js';
20+
import { findSessionOrFail, CASES_DIR, validatePathWithinBase } from '../route-helpers.js';
2121
import { SseEvent } from '../sse-events.js';
2222
import type { SessionPort, EventPort, ConfigPort, InfraPort } from '../ports/index.js';
2323

@@ -232,12 +232,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
232232
// Determine output directory for saving wizard results
233233
let outputDir: string | undefined;
234234
if (caseName) {
235-
const casePath = join(CASES_DIR, caseName);
236-
// Security: Path traversal protection - use relative path check
237-
const resolvedCase = resolve(casePath);
238-
const resolvedBase = resolve(CASES_DIR);
239-
const relPath = relative(resolvedBase, resolvedCase);
240-
if (!relPath.startsWith('..') && !isAbsolute(relPath) && existsSync(casePath)) {
235+
const casePath = validatePathWithinBase(caseName, CASES_DIR);
236+
if (casePath && existsSync(casePath)) {
241237
outputDir = join(casePath, 'ralph-wizard');
242238

243239
// Clear old ralph-wizard directory to ensure fresh prompts for each generation

src/web/routes/session-routes.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
import { FastifyInstance } from 'fastify';
8-
import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
8+
import { join, dirname } from 'node:path';
99
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
1010
import fs from 'node:fs/promises';
1111
import {
@@ -32,7 +32,7 @@ import {
3232
QuickRunSchema,
3333
QuickStartSchema,
3434
} from '../schemas.js';
35-
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH } from '../route-helpers.js';
35+
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH, validatePathWithinBase } from '../route-helpers.js';
3636
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
3737
import { writeHooksConfig, updateCaseEnvVars } from '../../hooks-config.js';
3838
import { generateClaudeMd } from '../../templates/claude-md.js';
@@ -788,13 +788,8 @@ export function registerSessionRoutes(
788788
}
789789
}
790790

791-
const casePath = join(CASES_DIR, caseName);
792-
793-
// Security: Path traversal protection - use relative path check
794-
const resolvedPath = resolve(casePath);
795-
const resolvedBase = resolve(CASES_DIR);
796-
const relPath = relative(resolvedBase, resolvedPath);
797-
if (relPath.startsWith('..') || isAbsolute(relPath)) {
791+
const casePath = validatePathWithinBase(caseName, CASES_DIR);
792+
if (!casePath) {
798793
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
799794
}
800795

0 commit comments

Comments
 (0)