chore: bump version to 0.1531

This commit is contained in:
arkon
2026-02-18 10:23:10 +01:00
parent e05811d69a
commit 62ddfdea36
9 changed files with 298 additions and 45 deletions
+39 -22
View File
@@ -1545,6 +1545,7 @@ class ClaudemanApp {
this.initTerminal();
this.loadFontSize();
this.applyHeaderVisibilitySettings();
this.applyTabWrapSettings();
this.applyMonitorVisibility();
// Remove mobile-init class now that JS has applied visibility settings.
// The inline <script> in <head> added this to prevent flash-of-content on mobile.
@@ -1569,6 +1570,7 @@ class ClaudemanApp {
// Load server-stored settings (async, re-applies visibility after load)
this.loadAppSettingsFromServer().then(() => {
this.applyHeaderVisibilitySettings();
this.applyTabWrapSettings();
this.applyMonitorVisibility();
});
}
@@ -5034,10 +5036,11 @@ class ClaudemanApp {
prompt += '## If Stuck\n';
prompt += 'Output `<promise>BLOCKED</promise>` with explanation';
// Show preview with highlighting
const highlightedPrompt = prompt
.replace(/<promise>/g, '<span class="preview-highlight">&lt;promise&gt;')
.replace(/<\/promise>/g, '&lt;/promise&gt;</span>')
// Show preview with highlighting (escape first, then apply formatting)
const escapedPrompt = this.escapeHtml(prompt);
const highlightedPrompt = escapedPrompt
.replace(/&lt;promise&gt;/g, '<span class="preview-highlight">&lt;promise&gt;')
.replace(/&lt;\/promise&gt;/g, '&lt;/promise&gt;</span>')
.replace(/`([^`]+)`/g, '<code>$1</code>');
preview.innerHTML = highlightedPrompt;
@@ -5411,7 +5414,7 @@ class ClaudemanApp {
if (metadata.synthesisStats?.sourceBreakdown) {
const sources = metadata.synthesisStats.sourceBreakdown;
const sourceList = Object.entries(sources)
.map(([src, count]) => `${src}: ${count}`)
.map(([src, count]) => `${this.escapeHtml(src)}: ${count}`)
.join(', ');
statsText += ` · Sources: ${sourceList}`;
}
@@ -5426,10 +5429,10 @@ class ClaudemanApp {
if (metadata?.verificationWarnings?.length > 0 || metadata?.verificationGaps?.length > 0) {
let warningsHtml = '';
if (metadata.verificationGaps?.length > 0) {
warningsHtml += `<div class="plan-gaps"><strong>Gaps identified:</strong> ${metadata.verificationGaps.join('; ')}</div>`;
warningsHtml += `<div class="plan-gaps"><strong>Gaps identified:</strong> ${metadata.verificationGaps.map(g => this.escapeHtml(g)).join('; ')}</div>`;
}
if (metadata.verificationWarnings?.length > 0) {
warningsHtml += `<div class="plan-warnings"><strong>Warnings:</strong> ${metadata.verificationWarnings.join('; ')}</div>`;
warningsHtml += `<div class="plan-warnings"><strong>Warnings:</strong> ${metadata.verificationWarnings.map(w => this.escapeHtml(w)).join('; ')}</div>`;
}
warningsEl.innerHTML = warningsHtml;
warningsEl.classList.remove('hidden');
@@ -6005,9 +6008,9 @@ class ClaudemanApp {
win.innerHTML = `
<div class="plan-subagent-header">
<span class="plan-subagent-prompt-link" data-agent-id="${agentId}" data-agent-type="${agentType}" title="Click to view prompt">
<span class="plan-subagent-prompt-link" data-agent-id="${this.escapeHtml(agentId)}" data-agent-type="${this.escapeHtml(agentType)}" title="Click to view prompt">
<span class="plan-subagent-icon">${typeIcons[agentType] || '🤖'}</span>
<span class="plan-subagent-title">${typeLabels[agentType] || agentType}</span>
<span class="plan-subagent-title">${typeLabels[agentType] || this.escapeHtml(agentType)}</span>
</span>
<span class="plan-subagent-model">${model}</span>
</div>
@@ -8086,8 +8089,8 @@ class ClaudemanApp {
// Shorter timer name display
const displayName = name.replace(/-/g, ' ').replace(/^\w/, c => c.toUpperCase());
html += `<div class="respawn-countdown-timer" title="${timer.reason || ''}">
<span class="timer-name">${displayName}</span>
html += `<div class="respawn-countdown-timer" title="${this.escapeHtml(timer.reason || '')}">
<span class="timer-name">${this.escapeHtml(displayName)}</span>
<span class="timer-value">${remainingSec}s</span>
<div class="respawn-timer-bar">
<div class="respawn-timer-progress" style="width: ${percent}%"></div>
@@ -8123,7 +8126,7 @@ class ClaudemanApp {
html += `<div class="respawn-action-entry${extraClass}">
<span class="action-time">${time}</span>
<span class="action-type">[${action.type}]</span>
<span class="action-detail">${action.detail}</span>
<span class="action-detail">${this.escapeHtml(action.detail)}</span>
</div>`;
}
@@ -9405,6 +9408,7 @@ class ClaudemanApp {
document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? defaults.subagentTrackingEnabled ?? true;
document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? defaults.subagentActiveTabOnly ?? true;
document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? defaults.imageWatcherEnabled ?? false;
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? true;
// Claude CLI settings
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
const allowedToolsRow = document.getElementById('allowedToolsRow');
@@ -9530,6 +9534,7 @@ class ClaudemanApp {
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
// Claude CLI settings
claudeMode: document.getElementById('appSettingsClaudeMode').value,
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
@@ -9616,6 +9621,7 @@ class ClaudemanApp {
// Apply header visibility immediately
this.applyHeaderVisibilitySettings();
this.applyTabWrapSettings();
this._updateTokensImmediate(); // Re-render token display (picks up showCost change)
this.applyMonitorVisibility();
this.renderProjectInsightsPanel(); // Re-render to apply visibility setting
@@ -9740,6 +9746,7 @@ class ClaudemanApp {
subagentActiveTabOnly: true, // Only show subagents for active tab
imageWatcherEnabled: false,
ralphTrackerEnabled: false,
tabTwoRows: false,
};
}
// Desktop defaults - rely on ?? operators in apply functions
@@ -9811,6 +9818,16 @@ class ClaudemanApp {
}
}
applyTabWrapSettings() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
const twoRows = settings.tabTwoRows ?? defaults.tabTwoRows ?? true;
const tabsEl = document.getElementById('sessionTabs');
if (tabsEl) {
tabsEl.classList.toggle('tabs-single-row', !twoRows);
}
}
applyMonitorVisibility() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
@@ -9951,7 +9968,7 @@ class ClaudemanApp {
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentTrackingEnabled', 'subagentActiveTabOnly',
'imageWatcherEnabled', 'ralphTrackerEnabled',
'imageWatcherEnabled', 'ralphTrackerEnabled', 'tabTwoRows',
]);
const filteredAppSettings = {};
for (const [key, value] of Object.entries(appSettings)) {
@@ -11349,19 +11366,19 @@ class ClaudemanApp {
let html = `
<div class="ralph-status-block-header">
<span>RALPH_STATUS</span>
<span class="ralph-status-block-status ${statusClass}">${statusBlock.status}</span>
<span class="ralph-status-block-status ${statusClass}">${this.escapeHtml(statusBlock.status)}</span>
${statusBlock.exitSignal ? '<span style="color: #4caf50;">🚪 EXIT</span>' : ''}
</div>
<div class="ralph-status-block-stats">
<span>${workIcon} ${statusBlock.workType}</span>
<span>${workIcon} ${this.escapeHtml(statusBlock.workType)}</span>
<span>📁 ${statusBlock.filesModified} files</span>
<span>✓ ${statusBlock.tasksCompletedThisLoop} tasks</span>
<span>${testsIcon} Tests: ${statusBlock.testsStatus}</span>
<span>✓ ${this.escapeHtml(String(statusBlock.tasksCompletedThisLoop))} tasks</span>
<span>${testsIcon} Tests: ${this.escapeHtml(statusBlock.testsStatus)}</span>
</div>
`;
if (statusBlock.recommendation) {
html += `<div class="ralph-status-block-recommendation">${statusBlock.recommendation}</div>`;
html += `<div class="ralph-status-block-recommendation">${this.escapeHtml(statusBlock.recommendation)}</div>`;
}
container.innerHTML = html;
@@ -11765,7 +11782,7 @@ class ClaudemanApp {
const hasWindow = this.subagentWindows.has(agent.agentId);
const canKill = agent.status === 'active' || agent.status === 'idle';
const modelBadge = agent.modelShort
? `<span class="subagent-model-badge ${agent.modelShort}">${agent.modelShort}</span>`
? `<span class="subagent-model-badge ${this.escapeHtml(agent.modelShort)}">${this.escapeHtml(agent.modelShort)}</span>`
: '';
const teammateInfo = this.getTeammateInfo(agent);
@@ -11869,7 +11886,7 @@ class ClaudemanApp {
const detailTitle = agent.description || `Agent ${agent.agentId}`;
const modelBadge = agent.modelShort
? `<span class="subagent-model-badge ${agent.modelShort}">${agent.modelShort}</span>`
? `<span class="subagent-model-badge ${this.escapeHtml(agent.modelShort)}">${this.escapeHtml(agent.modelShort)}</span>`
: '';
const tokenStats = (agent.totalInputTokens || agent.totalOutputTokens)
? `<span>Tokens: ${this.formatTokenCount(agent.totalInputTokens || 0)}↓ ${this.formatTokenCount(agent.totalOutputTokens || 0)}↑</span>`
@@ -12001,14 +12018,14 @@ class ClaudemanApp {
win.document.write(`
<html>
<head>
<title>Subagent ${agentId} Transcript</title>
<title>Subagent ${this.escapeHtml(agentId)} Transcript</title>
<style>
body { background: #1a1a2e; color: #eee; font-family: monospace; padding: 20px; }
pre { white-space: pre-wrap; word-wrap: break-word; }
</style>
</head>
<body>
<h2>Subagent ${agentId} Transcript (${data.data.entryCount} entries)</h2>
<h2>Subagent ${this.escapeHtml(agentId)} Transcript (${data.data.entryCount} entries)</h2>
<pre>${this.escapeHtml(content)}</pre>
</body>
</html>
+10
View File
@@ -790,6 +790,16 @@
</label>
</div>
<!-- Tab Bar Section -->
<div class="settings-section-header">Tab Bar</div>
<div class="settings-item" title="Allow tabs to wrap into two rows when there are many sessions">
<span class="settings-item-label">Two-Row Tabs</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsTabTwoRows" checked>
<span class="slider"></span>
</label>
</div>
<!-- Panels Section -->
<div class="settings-section-header">Panels</div>
<div class="settings-item" title="Show Monitor panel at bottom right">
+7
View File
@@ -183,6 +183,13 @@ body {
contain: layout;
}
.session-tabs.tabs-single-row {
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
max-height: none;
}
.session-tabs::-webkit-scrollbar {
width: 4px;
}
+54 -4
View File
@@ -9,6 +9,56 @@
import { z } from 'zod';
// ========== Path Validation ==========
/** Regex to validate working directory paths (no shell metacharacters) — matches tmux-manager.ts */
const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_\/\-. ~]+$/;
/** Validate a path string: no shell metacharacters, no traversal, must be absolute */
export function isValidWorkingDir(p: string): boolean {
if (!p || !p.startsWith('/')) return false;
if (p.includes(';') || p.includes('&') || p.includes('|') ||
p.includes('$') || p.includes('`') || p.includes('(') ||
p.includes(')') || p.includes('{') || p.includes('}') ||
p.includes('<') || p.includes('>') || p.includes("'") ||
p.includes('"') || p.includes('\n') || p.includes('\r')) {
return false;
}
if (p.includes('..')) return false;
return SAFE_PATH_PATTERN.test(p);
}
/** Zod refinement for safe absolute path */
const safePathSchema = z.string().max(1000).refine(isValidWorkingDir, {
message: 'Invalid path: must be absolute, no shell metacharacters or traversal',
});
// ========== Env Var Allowlist ==========
/** Allowlisted env var key prefixes */
const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_'];
/** Env var keys that are always blocked (security-sensitive) */
const BLOCKED_ENV_KEYS = new Set([
'PATH', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'NODE_OPTIONS',
'CLAUDEMAN_SCREEN_NAME', 'CLAUDEMAN_TMUX',
]);
/** Validate that an env var key is allowed */
function isAllowedEnvKey(key: string): boolean {
if (BLOCKED_ENV_KEYS.has(key)) return false;
return ALLOWED_ENV_PREFIXES.some(prefix => key.startsWith(prefix));
}
/** Zod schema for env overrides with allowlist enforcement */
const safeEnvOverridesSchema = z.record(z.string(), z.string()).optional().refine(
(val) => {
if (!val) return true;
return Object.keys(val).every(isAllowedEnvKey);
},
{ message: 'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_* keys are allowed.' },
);
// ========== Session Routes ==========
/**
@@ -16,10 +66,10 @@ import { z } from 'zod';
* Creates a new session with optional working directory, mode, and name.
*/
export const CreateSessionSchema = z.object({
workingDir: z.string().optional(),
workingDir: safePathSchema.optional(),
mode: z.enum(['claude', 'shell']).optional(),
name: z.string().max(100).optional(),
envOverrides: z.record(z.string(), z.string()).optional(),
envOverrides: safeEnvOverridesSchema,
});
/**
@@ -203,13 +253,13 @@ export const FlickerFilterSchema = z.object({
/** POST /api/run */
export const QuickRunSchema = z.object({
prompt: z.string().min(1).max(100000),
workingDir: z.string().max(1000).optional(),
workingDir: safePathSchema.optional(),
});
/** POST /api/scheduled */
export const ScheduledRunSchema = z.object({
prompt: z.string().min(1).max(100000),
workingDir: z.string().max(1000).optional(),
workingDir: safePathSchema.optional(),
durationMinutes: z.number().int().min(1).max(14400).optional(),
});
+70 -3
View File
@@ -15,7 +15,7 @@ import fastifyCompress from '@fastify/compress';
import fastifyStatic from '@fastify/static';
import path, { join, dirname, resolve, relative, isAbsolute } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { existsSync, statSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import fs from 'node:fs/promises';
import { execSync } from 'node:child_process';
import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os';
@@ -558,13 +558,38 @@ export class WebServer extends EventEmitter {
});
}
// Security headers on every response
this.app.addHook('onRequest', (_req, reply, done) => {
// Security headers + CORS on every response
this.app.addHook('onRequest', (req, reply, done) => {
reply.header('X-Content-Type-Options', 'nosniff');
reply.header('X-Frame-Options', 'SAMEORIGIN');
reply.header('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: blob:; connect-src 'self'; font-src 'self' https://cdn.jsdelivr.net; frame-ancestors 'self'");
if (this.https) {
reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
// CORS: restrict to same-origin (localhost) only
const origin = req.headers.origin;
if (origin) {
try {
const url = new URL(origin);
if (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1') {
reply.header('Access-Control-Allow-Origin', origin);
reply.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
reply.header('Access-Control-Max-Age', '86400');
}
} catch {
// Invalid origin header — do not set CORS headers
}
}
// Handle CORS preflight
if (req.method === 'OPTIONS') {
reply.code(204).send();
done();
return;
}
done();
});
@@ -742,6 +767,18 @@ export class WebServer extends EventEmitter {
const body = result.data;
const workingDir = body.workingDir || process.cwd();
// Validate workingDir exists and is a directory
if (body.workingDir) {
try {
const stat = statSync(workingDir);
if (!stat.isDirectory()) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
}
} catch {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
}
}
// Write env overrides to .claude/settings.local.json if provided
if (body.envOverrides && Object.keys(body.envOverrides).length > 0) {
await updateCaseEnvVars(workingDir, body.envOverrides);
@@ -2117,6 +2154,18 @@ export class WebServer extends EventEmitter {
}
const dir = workingDir || process.cwd();
// Validate workingDir exists and is a directory
if (workingDir) {
try {
const stat = statSync(dir);
if (!stat.isDirectory()) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
}
} catch {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
}
}
const session = new Session({ workingDir: dir });
this.sessions.set(session.id, session);
this.store.incrementSessionsCreated();
@@ -2149,6 +2198,18 @@ export class WebServer extends EventEmitter {
}
const { prompt, workingDir, durationMinutes } = srResult.data;
// Validate workingDir exists and is a directory
if (workingDir) {
try {
const stat = statSync(workingDir);
if (!stat.isDirectory()) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
}
} catch {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
}
}
const run = await this.startScheduledRun(prompt, workingDir || process.cwd(), durationMinutes ?? 60);
return { success: true, run };
});
@@ -5241,6 +5302,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// Start stats collection to show screen info
this.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS);
// Start mouse mode sync (tmux only) — toggles mouse on/off based on pane count.
// Mouse off = native xterm.js selection; mouse on = tmux pane clicking (split layouts).
if (this.mux.backend === 'tmux' && 'startMouseModeSync' in this.mux) {
(this.mux as { startMouseModeSync: (ms?: number) => void }).startMouseModeSync();
}
}
if (dead.length > 0) {