From 62ddfdea36ced176e29b2d8e10138d4dee17aec9 Mon Sep 17 00:00:00 2001 From: arkon Date: Wed, 18 Feb 2026 10:23:10 +0100 Subject: [PATCH] chore: bump version to 0.1531 --- CLAUDE.md | 4 +- package.json | 2 +- src/file-stream-manager.ts | 40 +++++++++++++---- src/tmux-manager.ts | 88 ++++++++++++++++++++++++++++++++++++-- src/web/public/app.js | 61 ++++++++++++++++---------- src/web/public/index.html | 10 +++++ src/web/public/styles.css | 7 +++ src/web/schemas.ts | 58 +++++++++++++++++++++++-- src/web/server.ts | 73 +++++++++++++++++++++++++++++-- 9 files changed, 298 insertions(+), 45 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 627dc466..ab44c03d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1530 (must match `package.json` for npm publish) +**Version**: 0.1531 (must match `package.json` for npm publish) ## Project Overview @@ -311,7 +311,7 @@ Use `LRUMap` for bounded caches with eviction, `StaleExpirationMap` for TTL-base | **SSE events** | Search `broadcast(` in `server.ts` | | **CLI commands** | `claudeman --help` | | **Frontend patterns** | `src/web/public/app.js` (subagent windows, notifications) | -| **Session modes** | `SessionMode` type in `src/types.ts` | +| **Session statuses** | `SessionStatus` type in `src/types.ts` | | **Error codes** | `createErrorResponse()` in `src/types.ts` | | **Test utilities** | `test/respawn-test-utils.ts` | | **Memory leak patterns** | `test/memory-leak-prevention.test.ts` | diff --git a/package.json b/package.json index f8d7af33..84376d23 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1530", + "version": "0.1531", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/file-stream-manager.ts b/src/file-stream-manager.ts index 187e1136..55baea4c 100644 --- a/src/file-stream-manager.ts +++ b/src/file-stream-manager.ts @@ -12,7 +12,7 @@ */ import { spawn, ChildProcess } from 'node:child_process'; -import { existsSync, statSync } from 'node:fs'; +import { existsSync, statSync, realpathSync } from 'node:fs'; import { resolve, relative, isAbsolute } from 'node:path'; import { homedir } from 'node:os'; import { EventEmitter } from 'node:events'; @@ -158,7 +158,7 @@ export class FileStreamManager extends EventEmitter { return { success: false, error: validationResult.error }; } - const absolutePath = validationResult.absolutePath!; + let absolutePath = validationResult.absolutePath!; // Check file exists and size try { @@ -175,6 +175,22 @@ export class FileStreamManager extends EventEmitter { return { success: false, error: 'File not found or not accessible' }; } + // Re-resolve symlinks right before spawn to minimize TOCTOU window. + // A symlink could have been swapped between validatePath() and here. + try { + const resolvedPath = realpathSync(absolutePath); + if (resolvedPath !== absolutePath) { + // Symlink target changed — re-validate against allowed paths + const recheck = this.validatePath(resolvedPath, workingDir); + if (!recheck.valid) { + return { success: false, error: recheck.error }; + } + absolutePath = resolvedPath; + } + } catch { + return { success: false, error: 'File not found or not accessible' }; + } + // Generate stream ID const streamId = `${sessionId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; @@ -363,19 +379,27 @@ export class FileStreamManager extends EventEmitter { } // Resolve to absolute path - const absolutePath = isAbsolute(expandedPath) + let absolutePath = isAbsolute(expandedPath) ? resolve(expandedPath) : resolve(workingDir, expandedPath); + // Resolve symlinks to prevent symlink attacks — validate the real target, + // not the symlink itself. Fall back to resolved path if file doesn't exist yet. + try { + absolutePath = realpathSync(absolutePath); + } catch { + // File may not exist yet (tail -f can wait); keep the resolved path + // which will be caught by the existsSync check below + } + // Normalize the working directory const normalizedWorkingDir = resolve(workingDir); // Check if the resolved path is within the working directory - // or common log directories + // or common log directories (/tmp intentionally excluded — world-writable) const allowedPaths = [ normalizedWorkingDir, '/var/log', - '/tmp', resolve(homedir(), '.local/share'), resolve(homedir(), '.cache'), resolve(homedir(), 'logs'), @@ -393,10 +417,8 @@ export class FileStreamManager extends EventEmitter { }; } - // Check for path traversal attempts - if (absolutePath.includes('..')) { - return { valid: false, error: 'Path traversal not allowed' }; - } + // Note: No need to check for '..' — resolve() already normalizes the path, + // and realpathSync() resolves symlinks. Both eliminate traversal sequences. // Check file exists if (!existsSync(absolutePath)) { diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index a8974f95..cd215323 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -136,6 +136,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { readonly backend = 'tmux' as const; private sessions: Map = new Map(); private statsInterval: NodeJS.Timeout | null = null; + private mouseSyncInterval: NodeJS.Timeout | null = null; + /** Track last-known pane count per session to avoid unnecessary tmux set-option calls */ + private lastPaneCount: Map = new Map(); private trueColorConfigured = false; @@ -298,14 +301,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { await new Promise(resolve => setTimeout(resolve, TMUX_CREATION_WAIT_MS)); // Non-critical tmux config — run in parallel to avoid blocking event loop. - // These configure UX niceties (no status bar, mouse mode, true color). + // These configure UX niceties (no status bar, true color). + // Mouse mode is OFF by default so xterm.js handles text selection natively. + // It gets enabled dynamically when panes are split (agent teams). const configPromises: Promise[] = [ // Disable tmux status bar — Claudeman's web UI provides session info execAsync(`tmux set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS }) .then(() => {}).catch(() => { /* Non-critical — session still works with status bar */ }), - // Enable mouse mode — allows clicking to select tmux panes - execAsync(`tmux set-option -t "${muxName}" mouse on`, { timeout: EXEC_TIMEOUT_MS }) - .then(() => {}).catch(() => { /* Non-critical — pane clicking won't work but keyboard input still does */ }), ]; // Enable 24-bit true color passthrough — server-wide, set once per lifetime @@ -535,6 +537,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { console.error(`[TmuxManager] Warning: Some processes may still be alive for session ${session.muxName}`); } + this.lastPaneCount.delete(session.muxName); this.sessions.delete(sessionId); this.saveSessions(); this.emit('sessionKilled', { sessionId }); @@ -813,8 +816,47 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } } + /** + * Start periodic mouse mode sync for all tracked sessions. + * Polls pane counts every 5s and toggles mouse on/off as needed. + * Only calls tmux set-option when the pane count actually changes. + */ + startMouseModeSync(intervalMs: number = 5000): void { + if (this.mouseSyncInterval) { + clearInterval(this.mouseSyncInterval); + } + + this.mouseSyncInterval = setInterval(() => { + if (IS_TEST_MODE) return; + for (const session of this.sessions.values()) { + const panes = this.listPanes(session.muxName); + const count = panes.length; + const prev = this.lastPaneCount.get(session.muxName); + + // Only toggle when pane count crosses the 1↔N boundary + if (prev !== count && count > 0) { + this.lastPaneCount.set(session.muxName, count); + if (count > 1) { + this.enableMouseMode(session.muxName); + } else { + this.disableMouseMode(session.muxName); + } + } + } + }, intervalMs); + } + + stopMouseModeSync(): void { + if (this.mouseSyncInterval) { + clearInterval(this.mouseSyncInterval); + this.mouseSyncInterval = null; + } + this.lastPaneCount.clear(); + } + destroy(): void { this.stopStatsCollection(); + this.stopMouseModeSync(); } registerSession(session: MuxSession): void { @@ -918,6 +960,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { /** * Enable mouse mode for an existing tmux session. * Allows clicking to select panes in agent team split-pane layouts. + * When mouse mode is on, tmux intercepts mouse events (slow selection, no browser copy). */ enableMouseMode(muxName: string): boolean { if (IS_TEST_MODE) return true; @@ -937,6 +980,43 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } } + /** + * Disable mouse mode for an existing tmux session. + * Restores native xterm.js text selection and browser clipboard copy. + */ + disableMouseMode(muxName: string): boolean { + if (IS_TEST_MODE) return true; + if (!isValidMuxName(muxName)) { + console.error('[TmuxManager] Invalid session name in disableMouseMode:', muxName); + return false; + } + + try { + execSync( + `tmux set-option -t "${muxName}" mouse off`, + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } + ); + return true; + } catch { + return false; + } + } + + /** + * Sync mouse mode based on pane count: enable if split (>1 pane), disable if single. + * Called by TeamWatcher when teammates spawn/despawn panes. + * Uses `tmux list-panes` for bulletproof detection — counts actual panes, not config. + */ + syncMouseMode(muxName: string): boolean { + if (IS_TEST_MODE) return true; + const panes = this.listPanes(muxName); + if (panes.length > 1) { + return this.enableMouseMode(muxName); + } else { + return this.disableMouseMode(muxName); + } + } + /** * List all panes in a tmux session. * Returns structured info for each pane. diff --git a/src/web/public/app.js b/src/web/public/app.js index 61a1eb2a..a6fb21d8 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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