diff --git a/scripts/browser-comparison.mjs b/scripts/browser-comparison.mjs index f780f8e6..a3eeee03 100644 --- a/scripts/browser-comparison.mjs +++ b/scripts/browser-comparison.mjs @@ -19,6 +19,10 @@ const PORTS = { const results = []; +function isCodemanTitle(title) { + return typeof title === 'string' && title.startsWith('codeman:'); +} + function logSection(title) { console.log('\n' + '='.repeat(60)); console.log(` ${title}`); @@ -88,7 +92,7 @@ async function main() { const page = await playwrightBrowser.newPage(); await page.goto(`http://localhost:${PORTS.playwright}`); const title = await page.title(); - if (title !== 'Codeman') throw new Error(`Expected Codeman, got ${title}`); + if (!isCodemanTitle(title)) throw new Error(`Expected codeman:, got ${title}`); await page.close(); }); @@ -149,7 +153,7 @@ async function main() { const page = await puppeteerBrowser.newPage(); await page.goto(`http://localhost:${PORTS.puppeteer}`); const title = await page.title(); - if (title !== 'Codeman') throw new Error(`Expected Codeman, got ${title}`); + if (!isCodemanTitle(title)) throw new Error(`Expected codeman:, got ${title}`); await page.close(); }); @@ -202,7 +206,7 @@ async function main() { agentBrowser(`open http://localhost:${PORTS.agentBrowser}`); await new Promise(r => setTimeout(r, 2000)); const title = agentBrowserJson('get title'); - agentBrowserAvailable = title.title === 'Codeman'; + agentBrowserAvailable = isCodemanTitle(title.title); console.log(' Browser launched'); // Test 1: Page load @@ -210,7 +214,7 @@ async function main() { agentBrowser(`open http://localhost:${PORTS.agentBrowser}`); await new Promise(r => setTimeout(r, 1000)); const title = agentBrowserJson('get title'); - if (title.title !== 'Codeman') throw new Error(`Expected Codeman, got ${title.title}`); + if (!isCodemanTitle(title.title)) throw new Error(`Expected codeman:, got ${title.title}`); }); // Test 2: Element selection diff --git a/src/cli.ts b/src/cli.ts index b4461043..2c7f396f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -485,16 +485,18 @@ program .description('Start the web interface') .option('-p, --port ', 'Port to listen on', '3000') .option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)') + .option('--title-hostname ', 'Override the hostname shown in the browser title') .action(async (options) => { const { startWebServer } = await import('./web/server.js'); const port = parseInt(options.port, 10); const https = !!options.https; + const titleHostname = options.titleHostname; const protocol = https ? 'https' : 'http'; console.log(chalk.cyan(`Starting Codeman web interface on port ${port}${https ? ' (HTTPS)' : ''}...`)); try { - const server = await startWebServer(port, https); + const server = await startWebServer(port, https, false, titleHostname); console.log(chalk.green(`\nāœ“ Web interface running at ${protocol}://localhost:${port}`)); if (https) { console.log(chalk.yellow(' Note: Accept the self-signed certificate in your browser on first visit')); diff --git a/src/web/public/notification-manager.js b/src/web/public/notification-manager.js index 9e661290..58801dd5 100644 --- a/src/web/public/notification-manager.js +++ b/src/web/public/notification-manager.js @@ -291,11 +291,11 @@ class NotificationManager { this.titleFlashInterval = setInterval(() => { this.titleFlashState = !this.titleFlashState; document.title = this.titleFlashState - ? `\u26A0\uFE0F (${this.unreadCount}) Codeman` + ? `\u26A0\uFE0F (${this.unreadCount}) ${this.originalTitle}` : this.originalTitle; }, TITLE_FLASH_INTERVAL_MS); // Set immediately - document.title = `\u26A0\uFE0F (${this.unreadCount}) Codeman`; + document.title = `\u26A0\uFE0F (${this.unreadCount}) ${this.originalTitle}`; } } } diff --git a/src/web/server.ts b/src/web/server.ts index 6bf22f4f..72faa355 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -37,7 +37,7 @@ import { fileURLToPath } from 'node:url'; import { existsSync, mkdirSync, readFileSync, chmodSync } from 'node:fs'; import fs from 'node:fs/promises'; import { execSync } from 'node:child_process'; -import { homedir } from 'node:os'; +import { homedir, hostname as getHostname } from 'node:os'; import { EventEmitter } from 'node:events'; import { Session, type BackgroundTask } from '../session.js'; import type { ClaudeMode, SessionState } from '../types.js'; @@ -119,6 +119,10 @@ import { const __dirname = dirname(fileURLToPath(import.meta.url)); +function escapeHtmlText(value: string): string { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); +} + import { SESSIONS_LIST_CACHE_TTL, SCHEDULED_CLEANUP_INTERVAL, @@ -226,12 +230,18 @@ export class WebServer extends EventEmitter { teamRemoved: (config: unknown) => void; taskUpdated: (data: unknown) => void; } | null = null; - constructor(port: number = 3000, https: boolean = false, testMode: boolean = false) { + private readonly titleHostname: string; + private readonly windowTitle: string; + private readonly indexHtmlTemplate: string; + constructor(port: number = 3000, https: boolean = false, testMode: boolean = false, titleHostname?: string) { super(); this.setMaxListeners(0); this.port = port; this.https = https; this.testMode = testMode; + this.titleHostname = titleHostname || getHostname(); + this.windowTitle = `codeman:${this.titleHostname}`; + this.indexHtmlTemplate = readFileSync(join(__dirname, 'public', 'index.html'), 'utf-8'); if (https) { const { key, cert } = getOrCreateSelfSignedCert(); @@ -526,6 +536,12 @@ export class WebServer extends EventEmitter { // Security headers + CORS registerSecurityHeaders(this.app, this.https); + this.app.get('/', async (_req, reply) => { + return reply.header('Cache-Control', 'no-cache').type('text/html; charset=utf-8').send(this.renderIndexHtml()); + }); + this.app.get('/index.html', async (_req, reply) => { + return reply.header('Cache-Control', 'no-cache').type('text/html; charset=utf-8').send(this.renderIndexHtml()); + }); // Service worker must never be cached — browsers check for SW updates on navigation this.app.get('/sw.js', async (_req, reply) => { return reply @@ -922,6 +938,13 @@ export class WebServer extends EventEmitter { this.broadcast(SseEvent.SessionDeleted, { id: sessionId }); } + private renderIndexHtml(): string { + return this.indexHtmlTemplate.replace( + 'Codeman', + `${escapeHtmlText(this.windowTitle)}` + ); + } + private async setupSessionListeners(session: Session): Promise { // Create run summary tracker for this session const summaryTracker = new RunSummaryTracker(session.id, session.name); @@ -1970,9 +1993,10 @@ export class WebServer extends EventEmitter { export async function startWebServer( port: number = 3000, https: boolean = false, - testMode: boolean = false + testMode: boolean = false, + titleHostname?: string ): Promise { - const server = new WebServer(port, https, testMode); + const server = new WebServer(port, https, testMode, titleHostname); await server.start(); return server; } diff --git a/test/file-link-click.test.ts b/test/file-link-click.test.ts index 0e37cafb..d1587ff5 100644 --- a/test/file-link-click.test.ts +++ b/test/file-link-click.test.ts @@ -49,8 +49,10 @@ async function waitForElement(selector: string, timeout = 10000): Promise(`get count "${selector}"`); if (count.count > 0) return true; - } catch { /* retry */ } - await new Promise(r => setTimeout(r, 500)); + } catch { + /* retry */ + } + await new Promise((r) => setTimeout(r, 500)); } return false; } @@ -74,7 +76,9 @@ function isVisible(selector: string): boolean { function closeBrowser() { try { browser('close'); - } catch { /* ignore */ } + } catch { + /* ignore */ + } } describe('File Link Click Tests', () => { @@ -95,14 +99,14 @@ describe('File Link Click Tests', () => { server = new WebServer(TEST_PORT, false, true); await server.start(); - await new Promise(r => setTimeout(r, 1000)); + await new Promise((r) => setTimeout(r, 1000)); // Test if browser is available try { browser(`open ${baseUrl}`); - await new Promise(r => setTimeout(r, 2000)); + await new Promise((r) => setTimeout(r, 2000)); const title = browserJson<{ title: string }>('get title'); - browserAvailable = title.title === 'Codeman'; + browserAvailable = title.title.startsWith('codeman:'); } catch (e) { console.warn('Browser not available, skipping browser tests:', (e as Error).message); browserAvailable = false; @@ -114,14 +118,18 @@ describe('File Link Click Tests', () => { for (const sessionId of createdSessions) { try { await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' }); - } catch { /* ignore */ } + } catch { + /* ignore */ + } } await server.stop(); // Cleanup test directory try { rmSync(testDir, { recursive: true, force: true }); - } catch { /* ignore */ } + } catch { + /* ignore */ + } }, 60000); it('should create shell session and display terminal output', async () => { @@ -142,7 +150,7 @@ describe('File Link Click Tests', () => { createdSessions.push(data.session.id); // Wait for session to appear in UI - await new Promise(r => setTimeout(r, 2000)); + await new Promise((r) => setTimeout(r, 2000)); // Check that terminal is visible const terminalExists = await waitForElement('.xterm-screen', 5000); @@ -167,7 +175,7 @@ describe('File Link Click Tests', () => { body: JSON.stringify({ input: command + '\r' }), }); - await new Promise(r => setTimeout(r, 2000)); + await new Promise((r) => setTimeout(r, 2000)); // Check if xterm contains the file path // The xterm link provider should detect "tail -f /path/to/file" pattern @@ -204,7 +212,7 @@ describe('File Link Click Tests', () => { // Click somewhere in the terminal where the tail -f line should be // This is approximate - the link detection works on hover browser('click ".xterm-screen"'); - await new Promise(r => setTimeout(r, 500)); + await new Promise((r) => setTimeout(r, 500)); } catch (e) { console.log('Click failed:', e); } @@ -243,10 +251,10 @@ describe('File Link Click Tests', () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input: `echo "${pattern}"\r` }), }); - await new Promise(r => setTimeout(r, 500)); + await new Promise((r) => setTimeout(r, 500)); } - await new Promise(r => setTimeout(r, 1000)); + await new Promise((r) => setTimeout(r, 1000)); // Verify patterns appear in terminal const terminalText = getText('.xterm-screen'); @@ -255,11 +263,12 @@ describe('File Link Click Tests', () => { } }, 60000); -it('should match file paths with various command patterns', () => { + it('should match file paths with various command patterns', () => { // Unit test for pattern matching logic - runs without browser // Pattern matches: tail -f /path, grep pattern /path, cat -n /path const cmdPattern = /(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]*\s+)*(\/[^\s"'<>|;&\n\x00-\x1f]+)/g; - const extPattern = /(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g; + const extPattern = + /(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g; const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g; // Test cmdPattern @@ -309,13 +318,14 @@ it('should match file paths with various command patterns', () => { }); it('should NOT match invalid or unsafe paths', () => { - const extPattern = /(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g; + const extPattern = + /(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g; const invalidCases = [ 'This is just text without paths', - './relative/path.log', // relative path - 'C:\\Windows\\path.log', // windows path - '/usr/bin/something.log', // /usr not in allowed prefixes + './relative/path.log', // relative path + 'C:\\Windows\\path.log', // windows path + '/usr/bin/something.log', // /usr not in allowed prefixes ]; for (const line of invalidCases) { @@ -337,7 +347,7 @@ it('should match file paths with various command patterns', () => { }); const data = await response.json(); expect(data.success).toBe(true); - sessionId = data.sessionId; // quick-start returns sessionId directly + sessionId = data.sessionId; // quick-start returns sessionId directly createdSessions.push(sessionId); }