diff --git a/.gitignore b/.gitignore index c979337e..1950c2b8 100644 --- a/.gitignore +++ b/.gitignore @@ -53,7 +53,17 @@ scripts/remotion/out/ # Artifacts that should not be tracked test-results/ tmp/ -public +# Root `public` (a symlink to scripts/remotion/public — local artifact). ANCHORED +# with a leading slash so it does NOT also match src/web/public (a bare `public` +# would swallow the whole web UI source dir and silently un-stage any new asset +# added there). No trailing slash so it still matches the symlink, not just dirs. +/public + +# Opt-in gesture overlay runtime assets: large MediaPipe wasm + model (~27 MB) +# fetched at build/install by scripts/fetch-gesture-assets.mjs, kept out of git. +# (The gesture bundle itself, gesture-codeman.js, IS tracked.) +src/web/public/gesture/wasm/ +src/web/public/gesture/*.task # Claude Code plan tracking plan.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 857aba75..a1969583 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # aicodeman +## 0.8.2 + +### Patch Changes + +- Session detach/undock, opt-in gesture-control overlay, multi-monitor spanning, new App-Settings toggles, and asset cache-busting. + - **Session detach/undock + instance isolation (#103):** Detach a session into its own solo (popup) window from the tab strip. Adds multi-instance isolation primitives in `src/config/instance.ts` (`getDataDir()`/`dataPath()`/`DEFAULT_TMUX_SOCKET`) keyed off `CODEMAN_INSTANCE`, so a beta can run side-by-side with prod without discovering/attaching to prod's live tmux sessions or clobbering its `state.json`. `CODEMAN_INSTANCE` defaults to the production layout (`~/.codeman`, `-L codeman`, port 3000), so master installs are unaffected. Adds `scripts/run-beta.sh` (`CODEMAN_INSTANCE=beta` + `CODEMAN_PORT=5000`). The legacy `~/.claudeman` migration is now scoped to the default instance only. Hardened detach edge cases. Tests: `test/config/instance.test.ts`. + - **Gesture-control overlay (Phase 5, opt-in via `CODEMAN_GESTURE=1`):** Camera hand-tracking overlay (self-hosted MediaPipe — wasm + model fetched at install/build via `scripts/fetch-gesture-assets.mjs` rather than committed). `CODEMAN_GESTURE=1` makes the feature _available_ (CSP widening + `/gesture/` assets + `window.__codemanGestureAvailable`); the per-user **Gesture Control (beta)** toggle (App Settings → Display → Input, default OFF) is the actual on/off and reloads the page to inject/remove the bundle. Dashboard-only (not solo popups). Labeled "(beta)" (#109). + - **Multi-monitor button:** Header button (opt-in via App Settings → Display → Header Displays) that POSTs `/api/system/span-displays` to spawn `scripts/span-codeman.sh` — a maximized browser `--app` window sized to the union of all displays, so the gesture layer's floating panels can drag across the physical monitor seam. Tests: `test/routes/system-span-displays.test.ts`. + - **New App-Settings toggles (#105):** Gesture control and the multi-monitor button are both opt-in (default OFF), with live show/hide on save. + - **Asset cache-busting:** `renderIndexHtml` appends `?v=` to every same-origin `.js`/`.css` reference; `index.html` is served `no-cache`, so a normal reload picks up edited modules/styles without a hard refresh. Tests: `test/render-index-html.test.ts`. + - **Gesture Control toggle placement:** the toggle now lives inside the existing **Input** settings section (alongside Local Echo / CJK Input / Extended Keyboard Bar) instead of a duplicate "Input" section; only the toggle itself is hidden when `CODEMAN_GESTURE=1` is unset, leaving the rest of the section intact. + - **Service env:** `scripts/codeman-web.service` now sets `CODEMAN_GESTURE=1` so the gesture feature is available on the local install (still gated behind the default-OFF per-user toggle). + - **Docs:** CLAUDE.md updated for the orchestrator loop, multi-monitor/span-displays, cache-busting, gesture/multi-monitor toggles, and structural-count fixes. + ## 0.8.1 ### Patch Changes diff --git a/CLAUDE.md b/CLAUDE.md index a48358c5..18c093c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co 2. **Frontend changes**: Use Playwright to load the page and assert the UI renders correctly. Use `waitUntil: 'domcontentloaded'` (not `networkidle` — SSE keeps the connection open). Wait 3-4s for polling/async data to populate, then check element visibility, text content, and CSS values 3. **Only after verification passes**, proceed with COM -The production server caches static files for 1 year (`maxAge: '1y'` in `server.ts`). After deploying frontend changes, users may need a hard refresh (Ctrl+Shift+R) to see updates. +The production server caches static files for 1 year, `immutable` (`maxAge: '1y'` in `server.ts`). To avoid stale frontend after a deploy, `renderIndexHtml` runs `cacheBustAssets(html)` — it appends `?v=` to **every same-origin `.js`/`.css`** reference (mtime memoized ~1s so a burst of renders is cheap; external/already-versioned/missing refs untouched). Because `index.html` is served `no-cache`, a **normal reload now picks up edited modules/styles — no hard refresh needed** (the gesture bundle is injected separately with its own `?v=`). If you add an asset referenced by an *absolute* URL or from JS rather than a `\n`); + } + // Gesture-control overlay (Phase 5): dashboard only (not solo popups, which + // have no tab strip). `CODEMAN_GESTURE=1` makes the feature *available* on + // this instance (it also widens CSP + serves the assets); the per-user + // `gestureControlEnabled` setting (App Settings → Input, default OFF) is the + // actual on/off. We expose `__codemanGestureAvailable` so the settings UI can + // show the toggle only when the feature is available, and inject the bundle + // (served same-origin from /gesture/, so 'self' covers it) only when enabled. + if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') { + html = html.replace('', `\n`); + if (settings.gestureControlEnabled === true) { + const v = this.gestureBundleVersion(); + html = html.replace( + '', + `\n` + ); + } + } + return html; + } + + /** mtime memo for asset cache-busting (keyed by absolute path). A full index + * render does one stat per script/link tag (~25-30); without this each `/`, + * `/index.html` and `/session/:id` hit would re-stat them all. A 1s TTL keeps + * a burst of renders cheap while still picking up an edited/redeployed file + * within a second (no server restart needed). */ + private _assetVersionMemo = new Map(); + private assetVersion(absPath: string): number | null { + const now = Date.now(); + const hit = this._assetVersionMemo.get(absPath); + if (hit && now - hit.ts < 1000) return hit.v; + try { + const v = Math.floor(statSync(absPath).mtimeMs); + this._assetVersionMemo.set(absPath, { v, ts: now }); + return v; + } catch { + return null; + } + } + + /** Cache-busting query for the gesture bundle: its mtime (memoized, see + * assetVersion). The bundle is served from /gesture/ with a 1-year cache, so + * without a version that changes on redeploy the browser would keep running a + * stale bundle forever. Empty string if the file is missing. */ + private gestureBundleVersion(): string { + const v = this.assetVersion(join(__dirname, 'public', 'gesture', 'gesture-codeman.js')); + return v === null ? '' : `?v=${v}`; + } + + /** Append ?v= to every same-origin .js/.css reference in the page so a + * normal reload always serves the latest. Codeman's static assets are sent + * with `Cache-Control: max-age=1y, immutable` and the script/link tags carry + * no version, so without this an edited module (panels-ui.js, styles.css, …) + * stays cached until a manual hard refresh. mtime is memoized (1s TTL) so a + * changed file is picked up with no server restart. External URLs (have a + * `:` scheme), already-versioned refs (have a `?`), and refs with no matching + * file on disk are left untouched. */ + private cacheBustAssets(html: string): string { + const publicDir = join(__dirname, 'public'); + return html.replace(/(\s(?:src|href)=")([^"?:]+\.(?:js|css))(")/g, (full, pre, ref, post) => { + const v = this.assetVersion(join(publicDir, ref)); + return v === null ? full : `${pre}${ref}?v=${v}${post}`; + }); } private async setupSessionListeners(session: Session): Promise { @@ -1088,7 +1211,7 @@ export class WebServer extends EventEmitter { // Helper to get custom CLAUDE.md template path from settings private async getDefaultClaudeMdPath(): Promise { - const settingsPath = join(homedir(), '.codeman', 'settings.json'); + const settingsPath = dataPath('settings.json'); try { const content = await fs.readFile(settingsPath, 'utf-8'); @@ -1106,13 +1229,16 @@ export class WebServer extends EventEmitter { // Read ~/.codeman/settings.json once and return the parsed object. // Cached for 2s to avoid redundant reads during session creation bursts. + // The settings PUT route writes the file without invalidating this cache, so + // callers that must observe a just-saved value (e.g. renderIndexHtml on a + // post-save reload) pass forceFresh=true to bypass the cache. private _settingsCache: { data: Record; ts: number } | null = null; - private async readSettings(): Promise> { + private async readSettings(forceFresh = false): Promise> { const now = Date.now(); - if (this._settingsCache && now - this._settingsCache.ts < 2000) { + if (!forceFresh && this._settingsCache && now - this._settingsCache.ts < 2000) { return this._settingsCache.data; } - const settingsPath = join(homedir(), '.codeman', 'settings.json'); + const settingsPath = dataPath('settings.json'); try { const content = await fs.readFile(settingsPath, 'utf-8'); const data = JSON.parse(content) as Record; @@ -1533,6 +1659,13 @@ export class WebServer extends EventEmitter { } async start(): Promise { + if (!isLoopbackBindHost(this.host) && !process.env.CODEMAN_PASSWORD && !this.allowUnauthenticatedNetwork) { + throw new Error( + 'Refusing to start Codeman on a non-loopback host without CODEMAN_PASSWORD. ' + + 'Set CODEMAN_PASSWORD or explicitly allow unauthenticated network access.' + ); + } + await this.setupRoutes(); const lifecycleLog = getLifecycleLog(); @@ -1559,19 +1692,23 @@ export class WebServer extends EventEmitter { this._eventLoopMonitor = startEventLoopMonitor(); } - await this.app.listen({ port: this.port, host: '0.0.0.0' }); + await this.app.listen({ port: this.port, host: this.host }); const protocol = this.https ? 'https' : 'http'; - console.log(`Codeman web interface running at ${protocol}://localhost:${this.port}`); + const displayHost = this.host === '0.0.0.0' ? 'localhost' : this.host; + console.log(`Codeman web interface running at ${protocol}://${displayHost}:${this.port}`); - // Security warning: server binds to 0.0.0.0 (all interfaces) — warn if no auth configured - if (!process.env.CODEMAN_PASSWORD) { + if (!isLoopbackBindHost(this.host) && !process.env.CODEMAN_PASSWORD && this.allowUnauthenticatedNetwork) { console.warn('\n⚠ WARNING: No CODEMAN_PASSWORD set — server is accessible without authentication.'); console.warn(' Anyone on your network can access and control Claude sessions.'); - console.warn(' Set CODEMAN_PASSWORD environment variable to enable auth.\n'); + console.warn( + ' This was explicitly allowed by --allow-unauthenticated-network or CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK.\n' + ); } // Set API URL for child processes (MCP server, spawned sessions) - process.env.CODEMAN_API_URL = `${protocol}://localhost:${this.port}`; + const apiHost = + this.host === '0.0.0.0' || this.host === 'localhost' || this.host === '::1' ? '127.0.0.1' : this.host; + process.env.CODEMAN_API_URL = `${protocol}://${apiHost}:${this.port}`; // Start scheduled runs cleanup timer this.cleanup.setInterval( @@ -1619,7 +1756,7 @@ export class WebServer extends EventEmitter { // Tunnel only starts when user clicks the toggle in the UI — never on boot. // Reset persisted tunnelEnabled so the UI toggle reflects actual state. if (await this.isTunnelEnabled()) { - const settingsPath = join(homedir(), '.codeman', 'settings.json'); + const settingsPath = dataPath('settings.json'); try { const content = await fs.readFile(settingsPath, 'utf-8'); const settings = JSON.parse(content); @@ -1640,7 +1777,7 @@ export class WebServer extends EventEmitter { * Check if subagent tracking is enabled in settings (default: true) */ private async isSubagentTrackingEnabled(): Promise { - const settingsPath = join(homedir(), '.codeman', 'settings.json'); + const settingsPath = dataPath('settings.json'); try { const content = await fs.readFile(settingsPath, 'utf-8'); const settings = JSON.parse(content); @@ -1658,7 +1795,7 @@ export class WebServer extends EventEmitter { * Check if image watcher is enabled in settings (default: false) */ private async isImageWatcherEnabled(): Promise { - const settingsPath = join(homedir(), '.codeman', 'settings.json'); + const settingsPath = dataPath('settings.json'); try { const content = await fs.readFile(settingsPath, 'utf-8'); const settings = JSON.parse(content); @@ -1676,7 +1813,7 @@ export class WebServer extends EventEmitter { * Check if Cloudflare tunnel is enabled in settings (default: false) */ private async isTunnelEnabled(): Promise { - const settingsPath = join(homedir(), '.codeman', 'settings.json'); + const settingsPath = dataPath('settings.json'); try { const content = await fs.readFile(settingsPath, 'utf-8'); const settings = JSON.parse(content); @@ -2063,9 +2200,11 @@ export async function startWebServer( port: number = 3000, https: boolean = false, testMode: boolean = false, - titleHostname?: string + host: string = '127.0.0.1', + titleHostname?: string, + allowUnauthenticatedNetwork: boolean = false ): Promise { - const server = new WebServer(port, https, testMode, titleHostname); + const server = new WebServer(port, https, testMode, host, titleHostname, allowUnauthenticatedNetwork); await server.start(); return server; } diff --git a/test/auth-security.test.ts b/test/auth-security.test.ts index db3ed2d1..b4387f22 100644 --- a/test/auth-security.test.ts +++ b/test/auth-security.test.ts @@ -3,26 +3,60 @@ * 1. Timing-safe password comparison (timingSafeEqual) * 2. Hook event endpoint restricted to localhost * 3. Session cookie TTL refresh on access - * 4. Startup warning when no password configured + * 4. Startup fails closed when network-bound without auth * 5. SSE client limit enforcement * 6. Logout endpoint invalidates session * 7. Settings schema rejects unknown fields * - * Port: 3160 (auth tests), 3161 (no-auth tests) + * Port: 3160 (auth tests), 3161 (loopback no-auth tests), 3162 (network override tests) */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; import { WebServer } from '../src/web/server.js'; +import { TmuxManager } from '../src/tmux-manager.js'; import { SettingsUpdateSchema } from '../src/web/schemas.js'; const AUTH_PORT = 3160; const NOAUTH_PORT = 3161; +const NETWORK_OVERRIDE_PORT = 3162; +const AUTH_RATE_LIMIT_PORT = 3220; const TEST_USER = 'admin'; const TEST_PASS = 'test-password-12345'; +vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true); + function basicAuthHeader(user: string, pass: string): string { return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64'); } +async function startAuthServer(port: number): Promise<{ server: WebServer; baseUrl: string }> { + process.env.CODEMAN_PASSWORD = TEST_PASS; + process.env.CODEMAN_USERNAME = TEST_USER; + const server = new WebServer(port, false, true); + await server.start(); + return { server, baseUrl: `http://localhost:${port}` }; +} + +async function getSessionCookie(baseUrl: string): Promise { + const res = await fetch(`${baseUrl}/api/status`, { + headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) }, + }); + expect(res.status).toBe(200); + const setCookie = res.headers.get('set-cookie'); + expect(setCookie).toBeTruthy(); + const cookieMatch = setCookie!.match(/codeman_session=([^;]+)/); + expect(cookieMatch).toBeTruthy(); + return `codeman_session=${cookieMatch![1]}`; +} + +async function exhaustAuthFailures(baseUrl: string, prefix: string): Promise { + for (let i = 0; i < 10; i++) { + const res = await fetch(`${baseUrl}/api/status`, { + headers: { Authorization: basicAuthHeader(TEST_USER, `${prefix}-${i}`) }, + }); + expect(res.status).toBe(401); + } +} + describe('Auth Security', () => { let server: WebServer; let baseUrl: string; @@ -154,29 +188,63 @@ describe('Auth Security', () => { }); describe('Rate Limiting', () => { - it('should block after too many failed attempts', async () => { - // Send 10 failed attempts - for (let i = 0; i < 10; i++) { - await fetch(`${baseUrl}/api/status`, { - headers: { Authorization: basicAuthHeader(TEST_USER, 'wrong-' + i) }, - }); - } + let rateServer: WebServer; + let rateBaseUrl: string; - // 11th attempt should be rate-limited - const res = await fetch(`${baseUrl}/api/status`, { - headers: { Authorization: basicAuthHeader(TEST_USER, 'wrong-again') }, - }); - expect(res.status).toBe(429); + beforeEach(async () => { + ({ server: rateServer, baseUrl: rateBaseUrl } = await startAuthServer(AUTH_RATE_LIMIT_PORT)); }); - it('should rate-limit even with correct credentials after lockout', async () => { - // After being rate-limited, even correct credentials should fail - const res = await fetch(`${baseUrl}/api/status`, { + afterEach(async () => { + await rateServer.stop(); + }); + + it('should rate-limit wrong credentials after too many failed attempts', async () => { + await exhaustAuthFailures(rateBaseUrl, 'cod21-wrong'); + + const res = await fetch(`${rateBaseUrl}/api/status`, { + headers: { Authorization: basicAuthHeader(TEST_USER, 'wrong-again') }, + }); + + expect(res.status).toBe(429); + expect(res.headers.get('retry-after')).toMatch(/^\d+$/); + }); + + it('should allow an existing valid session cookie during auth failure lockout', async () => { + const cookie = await getSessionCookie(rateBaseUrl); + await exhaustAuthFailures(rateBaseUrl, 'cod21-cookie'); + + const res = await fetch(`${rateBaseUrl}/api/status`, { + headers: { Cookie: cookie }, + }); + + expect(res.status).toBe(200); + }); + + it('should allow correct credentials to recover from auth failure lockout', async () => { + await exhaustAuthFailures(rateBaseUrl, 'cod21-recover'); + + const res = await fetch(`${rateBaseUrl}/api/status`, { headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) }, }); - // Rate limit is per-IP and the previous test used the same IP - // This test verifies rate limiting isn't bypassed by correct creds - expect(res.status).toBe(429); + + expect(res.status).toBe(200); + expect(res.headers.get('set-cookie')).toContain('codeman_session='); + }); + + it('should clear failed attempt count after correct credentials recover access', async () => { + await exhaustAuthFailures(rateBaseUrl, 'cod21-clear'); + + const recoveryRes = await fetch(`${rateBaseUrl}/api/status`, { + headers: { Authorization: basicAuthHeader(TEST_USER, TEST_PASS) }, + }); + expect(recoveryRes.status).toBe(200); + + const wrongAfterRecovery = await fetch(`${rateBaseUrl}/api/status`, { + headers: { Authorization: basicAuthHeader(TEST_USER, 'wrong-after-recovery') }, + }); + + expect(wrongAfterRecovery.status).toBe(401); }); }); @@ -220,7 +288,7 @@ describe('Settings Schema Security', () => { it('should enforce tunnelEnabled as boolean', () => { const result = SettingsUpdateSchema.safeParse({ - tunnelEnabled: 'yes', // truthy string — should be rejected + tunnelEnabled: 'yes', // truthy string — should be rejected }); expect(result.success).toBe(false); }); @@ -264,40 +332,69 @@ describe('Settings Schema Security', () => { expect(validResult.success).toBe(true); const invalidResult = SettingsUpdateSchema.safeParse({ - nice: { enabled: true, niceValue: 100 }, // Out of range + nice: { enabled: true, niceValue: 100 }, // Out of range }); expect(invalidResult.success).toBe(false); }); }); -describe('No-Auth Server Warning', () => { +describe('No-Auth Server Startup Policy', () => { let server: WebServer; - let consoleWarnSpy: string[] = []; - const originalWarn = console.warn; beforeAll(async () => { delete process.env.CODEMAN_PASSWORD; delete process.env.CODEMAN_USERNAME; - consoleWarnSpy = []; - console.warn = (...args: unknown[]) => { - consoleWarnSpy.push(args.map(String).join(' ')); - }; - server = new WebServer(NOAUTH_PORT, false, true); + delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK; + server = new WebServer(NOAUTH_PORT, false, true, '127.0.0.1'); await server.start(); }); afterAll(async () => { - console.warn = originalWarn; + delete process.env.CODEMAN_PASSWORD; + delete process.env.CODEMAN_USERNAME; + delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK; await server.stop(); }); - it('should warn when no CODEMAN_PASSWORD is set', () => { - const hasWarning = consoleWarnSpy.some(msg => msg.includes('No CODEMAN_PASSWORD set')); - expect(hasWarning).toBe(true); - }); - - it('should allow requests without auth when no password configured', async () => { + it('allows loopback requests without auth when no password is configured', async () => { const res = await fetch(`http://localhost:${NOAUTH_PORT}/api/status`); expect(res.status).toBe(200); }); + + it('rejects non-loopback startup without a password or explicit override', async () => { + const networkServer = new WebServer(0, false, true, '0.0.0.0'); + + await expect(networkServer.start()).rejects.toThrow(/CODEMAN_PASSWORD/); + + await networkServer.stop(); + }); + + it('allows non-loopback startup when CODEMAN_PASSWORD is configured', async () => { + process.env.CODEMAN_PASSWORD = TEST_PASS; + const networkServer = new WebServer(0, false, true, '0.0.0.0'); + + await networkServer.start(); + await networkServer.stop(); + + delete process.env.CODEMAN_PASSWORD; + }); + + it('allows non-loopback startup with the explicit unauthenticated-network override', async () => { + const networkServer = new WebServer(NETWORK_OVERRIDE_PORT, false, true, '0.0.0.0', undefined, true); + + await networkServer.start(); + const res = await fetch(`http://localhost:${NETWORK_OVERRIDE_PORT}/api/status`); + expect(res.status).toBe(200); + await networkServer.stop(); + }); + + it('allows non-loopback startup with the explicit unauthenticated-network env override', async () => { + process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK = 'true'; + const networkServer = new WebServer(0, false, true, '0.0.0.0'); + + await networkServer.start(); + await networkServer.stop(); + + delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK; + }); }); diff --git a/test/cli-commands.test.ts b/test/cli-commands.test.ts index db903c41..ae65fb5d 100644 --- a/test/cli-commands.test.ts +++ b/test/cli-commands.test.ts @@ -5,6 +5,7 @@ */ import { describe, it, expect } from 'vitest'; +import { program } from '../src/cli.js'; describe('CLI Command Parsing', () => { describe('Command Structure', () => { @@ -72,11 +73,11 @@ describe('CLI Command Parsing', () => { ]; const findCommand = (name: string): Command | undefined => { - return commands.find(c => c.name === name || c.aliases.includes(name)); + return commands.find((c) => c.name === name || c.aliases.includes(name)); }; const findSubcommand = (parent: Command, name: string): Command | undefined => { - return parent.subcommands?.find(c => c.name === name || c.aliases.includes(name)); + return parent.subcommands?.find((c) => c.name === name || c.aliases.includes(name)); }; it('should find commands by name', () => { @@ -103,7 +104,7 @@ describe('CLI Command Parsing', () => { }); it('should have descriptions for all commands', () => { - commands.forEach(cmd => { + commands.forEach((cmd) => { expect(cmd.description).toBeTruthy(); }); }); @@ -267,13 +268,13 @@ describe('CLI Command Parsing', () => { }); it('should have defaults for web flags', () => { - webFlags.forEach(flag => { + webFlags.forEach((flag) => { expect(flag.default).toBeDefined(); }); }); it('should have defaults for tui flags', () => { - tuiFlags.forEach(flag => { + tuiFlags.forEach((flag) => { expect(flag.default).toBeDefined(); }); }); @@ -322,7 +323,7 @@ describe('CLI Command Parsing', () => { help += `${description}\n`; if (options.length > 0) { help += '\nOptions:\n'; - options.forEach(opt => { + options.forEach((opt) => { help += ` ${opt}\n`; }); } @@ -345,6 +346,15 @@ describe('CLI Command Parsing', () => { expect(help).toContain('--host'); }); + it('documents the unauthenticated network override in real web command help', () => { + const webCommand = program.commands.find((command) => command.name() === 'web'); + expect(webCommand).toBeDefined(); + + const help = webCommand!.helpInformation(); + expect(help).toContain('--allow-unauthenticated-network'); + expect(help).toMatch(/without\s+CODEMAN_PASSWORD/); + }); + it('should format properly', () => { const help = generateHelp('test', 'Test command', ['--flag']); const lines = help.split('\n'); @@ -474,22 +484,27 @@ describe('CLI Output Formatting', () => { } const formatRow = (values: string[], columns: Column[]): string => { - return values.map((val, i) => { - const width = columns[i]?.width || 10; - return val.padEnd(width).substring(0, width); - }).join(' '); + return values + .map((val, i) => { + const width = columns[i]?.width || 10; + return val.padEnd(width).substring(0, width); + }) + .join(' '); }; const formatTable = (headers: string[], rows: string[][], widths: number[]): string => { const columns = headers.map((h, i) => ({ header: h, width: widths[i] })); const headerRow = formatRow(headers, columns); - const separator = columns.map(c => '-'.repeat(c.width)).join(' '); - const dataRows = rows.map(row => formatRow(row, columns)); + const separator = columns.map((c) => '-'.repeat(c.width)).join(' '); + const dataRows = rows.map((row) => formatRow(row, columns)); return [headerRow, separator, ...dataRows].join('\n'); }; it('should format single row', () => { - const columns = [{ header: 'ID', width: 10 }, { header: 'Status', width: 8 }]; + const columns = [ + { header: 'ID', width: 10 }, + { header: 'Status', width: 8 }, + ]; const row = formatRow(['123', 'active'], columns); expect(row).toBe('123 active '); }); @@ -503,7 +518,10 @@ describe('CLI Output Formatting', () => { it('should format complete table', () => { const table = formatTable( ['ID', 'Status'], - [['1', 'active'], ['2', 'idle']], + [ + ['1', 'active'], + ['2', 'idle'], + ], [5, 8] ); expect(table).toContain('ID'); @@ -587,7 +605,7 @@ describe('CLI Output Formatting', () => { }); it('should format normal costs with 2 decimals', () => { - expect(formatCost(1.50)).toBe('$1.50'); + expect(formatCost(1.5)).toBe('$1.50'); expect(formatCost(0.05)).toBe('$0.05'); }); @@ -633,7 +651,7 @@ describe('CLI Output Formatting', () => { describe('List Formatting', () => { const formatList = (items: string[], bullet: string = '-'): string => { - return items.map(item => `${bullet} ${item}`).join('\n'); + return items.map((item) => `${bullet} ${item}`).join('\n'); }; const formatNumberedList = (items: string[]): string => { diff --git a/test/config/instance.test.ts b/test/config/instance.test.ts new file mode 100644 index 00000000..867a526f --- /dev/null +++ b/test/config/instance.test.ts @@ -0,0 +1,79 @@ +/** + * Per-instance isolation (src/config/instance.ts): the data dir + tmux socket + * derive from CODEMAN_INSTANCE, defaulting to the production layout so the + * feature branch is safe to merge to master. + * + * instance.ts reads env at module load, so each case re-imports it via + * vi.resetModules() under a controlled env. node:fs mkdirSync is mocked so + * getDataDir() never creates real directories on the test machine. + * + * Port: N/A (no server). + */ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +vi.mock('node:fs', async (orig) => { + const actual = await orig(); + return { ...actual, mkdirSync: vi.fn() }; +}); + +const ENV_KEYS = ['CODEMAN_INSTANCE', 'CODEMAN_DATA_DIR'] as const; +const ORIG: Record = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + +async function load(env: Partial> = {}) { + vi.resetModules(); + for (const k of ENV_KEYS) { + const v = env[k]; + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + return import('../../src/config/instance.js'); +} + +afterEach(() => { + for (const k of ENV_KEYS) { + if (ORIG[k] === undefined) delete process.env[k]; + else process.env[k] = ORIG[k]; + } + vi.resetModules(); +}); + +describe('config/instance', () => { + it('defaults to the production layout when CODEMAN_INSTANCE is unset', async () => { + const m = await load({ CODEMAN_INSTANCE: undefined, CODEMAN_DATA_DIR: undefined }); + expect(m.CODEMAN_INSTANCE).toBe(''); + expect(m.DEFAULT_TMUX_SOCKET).toBe('codeman'); + expect(m.getDataDir()).toBe(join(homedir(), '.codeman')); + expect(m.dataPath('state.json')).toBe(join(homedir(), '.codeman', 'state.json')); + }); + + it('treats an explicitly-empty CODEMAN_INSTANCE as the production layout', async () => { + const m = await load({ CODEMAN_INSTANCE: '', CODEMAN_DATA_DIR: undefined }); + expect(m.CODEMAN_INSTANCE).toBe(''); + expect(m.DEFAULT_TMUX_SOCKET).toBe('codeman'); + expect(m.getDataDir()).toBe(join(homedir(), '.codeman')); + }); + + it('scopes BOTH the data dir and the tmux socket for a named instance', async () => { + const m = await load({ CODEMAN_INSTANCE: 'beta', CODEMAN_DATA_DIR: undefined }); + expect(m.CODEMAN_INSTANCE).toBe('beta'); + expect(m.DEFAULT_TMUX_SOCKET).toBe('codeman-beta'); + expect(m.getDataDir()).toBe(join(homedir(), '.codeman-beta')); + expect(m.dataPath('mux-sessions.json')).toBe(join(homedir(), '.codeman-beta', 'mux-sessions.json')); + }); + + it('supports an arbitrary instance name', async () => { + const m = await load({ CODEMAN_INSTANCE: 'foo', CODEMAN_DATA_DIR: undefined }); + expect(m.DEFAULT_TMUX_SOCKET).toBe('codeman-foo'); + expect(m.getDataDir()).toBe(join(homedir(), '.codeman-foo')); + }); + + it('CODEMAN_DATA_DIR overrides the derived data dir (socket still instance-scoped)', async () => { + const m = await load({ CODEMAN_INSTANCE: 'beta', CODEMAN_DATA_DIR: '/tmp/codeman-test-xyz' }); + expect(m.getDataDir()).toBe('/tmp/codeman-test-xyz'); + expect(m.dataPath('a', 'b')).toBe(join('/tmp/codeman-test-xyz', 'a', 'b')); + // Socket is derived from the instance name, not the data dir override. + expect(m.DEFAULT_TMUX_SOCKET).toBe('codeman-beta'); + }); +}); diff --git a/test/mobile/keyboard.test.ts b/test/mobile/keyboard.test.ts index 48c701f0..92491627 100644 --- a/test/mobile/keyboard.test.ts +++ b/test/mobile/keyboard.test.ts @@ -5,16 +5,22 @@ import { PORTS, KEYBOARD, SELECTORS, BODY_CLASSES, WAIT } from './helpers/consta import { createTestServer, stopTestServer } from './helpers/server.js'; import { createDevicePage, getBrowser, closeAllBrowsers } from './helpers/browser.js'; import { - showKeyboard, hideKeyboard, - showKeyboardViaCDP, hideKeyboardViaCDP, - showKeyboardViaMock, hideKeyboardViaMock, - showKeyboardViaDOM, hideKeyboardViaDOM, + showKeyboard, + hideKeyboard, + showKeyboardViaCDP, + hideKeyboardViaCDP, + showKeyboardViaMock, + hideKeyboardViaMock, + showKeyboardViaDOM, + hideKeyboardViaDOM, setupViewportMock, } from './helpers/keyboard-sim.js'; import { getCDP, setVisualViewportHeight } from './helpers/cdp.js'; import { - assertHasClass, assertNotHasClass, - assertVisible, assertHidden, + assertHasClass, + assertNotHasClass, + assertVisible, + assertHidden, getCSSProperty, } from './helpers/assertions.js'; import { REPRESENTATIVE_DEVICES } from './devices.js'; @@ -167,9 +173,7 @@ describe('Virtual Keyboard', () => { const success = await showKeyboardViaMock(page, KEYBOARD.TYPICAL_IOS_HEIGHT); expect(success).toBe(true); - const hasClass = await page.evaluate(() => - document.body.classList.contains('keyboard-visible'), - ); + const hasClass = await page.evaluate(() => document.body.classList.contains('keyboard-visible')); expect(hasClass).toBe(true); } finally { await context.close(); @@ -280,12 +284,13 @@ describe('Virtual Keyboard', () => { expect(mainPadding).toBe(''); }); - it('accessory bar has 7 action buttons', async () => { - const count = await page.evaluate(() => { - const buttons = document.querySelectorAll('.keyboard-accessory-bar [data-action]'); - return buttons.length; + it('accessory bar has the simple-mode action buttons', async () => { + const actions = await page.evaluate(() => { + return Array.from(document.querySelectorAll('.keyboard-accessory-bar [data-action]')).map( + (button) => (button as HTMLElement).dataset.action + ); }); - expect(count).toBe(7); + expect(actions).toEqual(['scroll-up', 'scroll-down', 'init', 'clear', 'paste', 'dismiss']); }); it('double-tap confirm on /clear button', async () => { @@ -321,27 +326,6 @@ describe('Virtual Keyboard', () => { expect(text).toBe('Tap again'); }); - it('double-tap confirm on /compact button', async () => { - await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT); - await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION); - - await page.evaluate(` - if (typeof app !== 'undefined') app.activeSessionId = 'test-session'; - `); - - await page.evaluate(() => { - const btn = document.querySelector('[data-action="compact"]') as HTMLElement; - btn?.click(); - }); - await page.waitForTimeout(100); - - const confirming = await page.evaluate(() => { - const btn = document.querySelector('[data-action="compact"]'); - return btn?.classList.contains('confirming') ?? false; - }); - expect(confirming).toBe(true); - }); - it('double-tap expires after 2s', async () => { await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT); await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION); diff --git a/test/network-auth-policy.test.ts b/test/network-auth-policy.test.ts new file mode 100644 index 00000000..143c34a4 --- /dev/null +++ b/test/network-auth-policy.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { isExplicitlyEnabled, isLoopbackBindHost } from '../src/web/network-auth-policy.js'; + +describe('network auth policy', () => { + it.each(['localhost', '127.0.0.1', '127.42.0.9', '::1', '[::1]', '0:0:0:0:0:0:0:1', '::ffff:127.0.0.1'])( + 'treats %s as loopback', + (host) => { + expect(isLoopbackBindHost(host)).toBe(true); + } + ); + + it.each(['0.0.0.0', '192.168.1.10', '10.0.0.1', 'example.com', '::', '[::]', '::ffff:192.168.1.10'])( + 'treats %s as non-loopback', + (host) => { + expect(isLoopbackBindHost(host)).toBe(false); + } + ); + + it.each(['1', 'true', 'TRUE', ' yes ', 'on'])('treats %s as an explicit opt-in', (value) => { + expect(isExplicitlyEnabled(value)).toBe(true); + }); + + it.each([undefined, '', '0', 'false', 'no', 'off', 'enabled'])('does not treat %s as an explicit opt-in', (value) => { + expect(isExplicitlyEnabled(value)).toBe(false); + }); +}); diff --git a/test/operation-lightspeed.test.ts b/test/operation-lightspeed.test.ts index 2e3dbfec..c1c3a351 100644 --- a/test/operation-lightspeed.test.ts +++ b/test/operation-lightspeed.test.ts @@ -1256,7 +1256,7 @@ describe('Operation Lightspeed', () => { await Promise.all(ids.map((id) => deleteSession(baseUrl, id))); }); - it('should correctly filter SSE under concurrent session lifecycle', async () => { + it('should broadcast lifecycle events while filtering concurrent session terminal streams', async () => { // Create 2 sessions const target = await createSession(baseUrl); const other = await createSession(baseUrl); @@ -1309,15 +1309,21 @@ describe('Operation Lightspeed', () => { const events = parseSSEEvents(receivedData); - // Should see target's rename but not other's events - const targetUpdated = events.find((e) => e.event === 'session:updated' && (e.data as any).id === target); + // session:updated is a lifecycle event broadcast to all clients; the + // subscription filter applies only to high-volume terminal streams. + const updatedEvents = events.filter((e) => e.event === 'session:updated'); + const targetUpdated = updatedEvents.find((e) => (e.data as any).id === target); expect(targetUpdated).toBeDefined(); - // Should NOT see other's events - const otherEvents = events.filter( - (e) => ((e.data as any)?.id === other || (e.data as any)?.sessionId === other) && e.event !== 'init' + const otherLifecycleEvents = events.filter( + (e) => e.event !== 'init' && e.event !== 'session:terminal' && (e.data as any)?.id === other ); - expect(otherEvents.length).toBe(0); + expect(otherLifecycleEvents.length).toBeGreaterThan(0); + + const otherTerminalEvents = events.filter( + (e) => e.event === 'session:terminal' && (e.data as any)?.sessionId === other + ); + expect(otherTerminalEvents.length).toBe(0); await deleteSession(baseUrl, target); }); diff --git a/test/perf-browser.test.ts b/test/perf-browser.test.ts index c699cf9f..39f13a6c 100644 --- a/test/perf-browser.test.ts +++ b/test/perf-browser.test.ts @@ -19,24 +19,24 @@ const BASE_URL = `http://localhost:${PORT}`; // Thresholds (ms) const THRESHOLDS = { - PAGE_LOAD: 3000, // Full page load including JS init - DOMContentLoaded: 1500, // HTML parsed - SSE_CONNECT: 2000, // SSE EventSource open - TAB_CREATE_API: 200, // POST /api/sessions response - TAB_RENDER: 300, // Tab element appears in DOM - TAB_SWITCH: 400, // Tab click to active class applied (includes tmux session creation) - TERMINAL_INIT: 500, // xterm.js instance created for tab - INPUT_ROUNDTRIP: 500, // Keystroke sent via API → acknowledged - SETTINGS_OPEN: 300, // Settings modal visible - SETTINGS_CLOSE: 200, // Settings modal hidden + PAGE_LOAD: 3000, // Full page load including JS init + DOMContentLoaded: 1500, // Browser nav timing through deferred script execution + SSE_CONNECT: 2000, // SSE EventSource open + TAB_CREATE_API: 200, // POST /api/sessions response + TAB_RENDER: 300, // Tab element appears in DOM + TAB_SWITCH: 400, // Tab click to active class applied (includes tmux session creation) + TERMINAL_INIT: 500, // xterm.js instance created for tab + INPUT_ROUNDTRIP: 500, // Keystroke sent via API → acknowledged + SETTINGS_OPEN: 300, // Settings modal visible + SETTINGS_CLOSE: 200, // Settings modal hidden SESSION_OPTIONS_OPEN: 300, // Session options modal visible - SESSION_OPTIONS_TAB: 200, // Modal tab switch + SESSION_OPTIONS_TAB: 200, // Modal tab switch SUBAGENT_WINDOW_OPEN: 400, // Subagent window rendered SUBAGENT_WINDOW_CLOSE: 200, - BULK_TAB_CREATE: 3000, // Create 10 sessions - BULK_TAB_SWITCH_AVG: 300, // Average per-tab switch across 10 tabs (includes buffer loads) - MEMORY_HEAP_MB: 200, // Max JS heap after heavy load - BUFFER_LOAD_16KB: 500, // Load a 16KB terminal buffer + BULK_TAB_CREATE: 3000, // Create 10 sessions + BULK_TAB_SWITCH_AVG: 300, // Average per-tab switch across 10 tabs (includes buffer loads) + MEMORY_HEAP_MB: 200, // Max JS heap after heavy load + BUFFER_LOAD_16KB: 500, // Load a 16KB terminal buffer }; let server: WebServer; @@ -88,6 +88,25 @@ async function measure(fn: () => Promise): Promise { return performance.now() - start; } +type BrowserNavigationTiming = { + domInteractive: number; + domContentLoadedEventEnd: number; + loadEventEnd: number; +}; + +/** Get the browser's own navigation timing, excluding Playwright harness overhead. */ +async function getBrowserNavigationTiming(page: Page): Promise { + return page.evaluate(() => { + const entry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined; + if (!entry) throw new Error('Navigation timing entry not available'); + return { + domInteractive: entry.domInteractive, + domContentLoadedEventEnd: entry.domContentLoadedEventEnd, + loadEventEnd: entry.loadEventEnd, + }; + }); +} + /** Get JS heap size in MB (Chromium only) */ async function getHeapMB(page: Page): Promise { const metrics = await page.evaluate(() => { @@ -123,12 +142,15 @@ describe('Page load performance', () => { it('DOMContentLoaded fires within threshold', async () => { ({ context, page } = await freshPage()); - const timing = await measure(async () => { + const wallTiming = await measure(async () => { await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); }); + const navigationTiming = await getBrowserNavigationTiming(page); - console.log(`[page load] DOMContentLoaded: ${timing.toFixed(0)}ms`); - expect(timing).toBeLessThan(THRESHOLDS.DOMContentLoaded); + console.log( + `[page load] DOMContentLoaded: ${navigationTiming.domContentLoadedEventEnd.toFixed(0)}ms (wall ${wallTiming.toFixed(0)}ms)` + ); + expect(navigationTiming.domContentLoadedEventEnd).toBeLessThan(THRESHOLDS.DOMContentLoaded); }); it('full app initialization completes within threshold', async () => { @@ -157,7 +179,7 @@ describe('Page load performance', () => { const dot = document.getElementById('connectionDot'); return dot?.classList.contains('connected') || indicator.style.display === 'none'; }, - { timeout: 5000 }, + { timeout: 5000 } ); }); @@ -225,7 +247,7 @@ describe('Session tab creation', () => { await page.waitForFunction( (expected: number) => document.querySelectorAll('.session-tab').length > expected, tabCountBefore, - { timeout: 3000 }, + { timeout: 3000 } ); }); @@ -250,7 +272,7 @@ describe('Session tab creation', () => { if (!container) return false; return container.querySelector('.xterm-screen') !== null; }, - { timeout: 3000 }, + { timeout: 3000 } ); }); @@ -277,7 +299,7 @@ describe('Tab switching performance', () => { await page.waitForFunction( (count: number) => document.querySelectorAll('.session-tab').length >= count, sessionIds.length, - { timeout: 5000 }, + { timeout: 5000 } ); // Select first tab await page.locator(`.session-tab[data-id="${sessionIds[0]}"]`).click(); @@ -303,7 +325,7 @@ describe('Tab switching performance', () => { await page.waitForFunction( (id: string) => document.querySelector(`.session-tab[data-id="${id}"]`)?.classList.contains('active'), targetId, - { timeout: 2000 }, + { timeout: 2000 } ); }); timings.push(timing); @@ -325,7 +347,7 @@ describe('Tab switching performance', () => { await page.waitForFunction( (id: string) => document.querySelector(`.session-tab[data-id="${id}"]`)?.classList.contains('active'), targetId, - { timeout: 2000 }, + { timeout: 2000 } ); }); timings.push(timing); @@ -362,11 +384,9 @@ describe('Bulk tab operations', () => { sessionIds.push(id); } // Wait for all tabs to render - await page.waitForFunction( - (count: number) => document.querySelectorAll('.session-tab').length >= count, - 10, - { timeout: 5000 }, - ); + await page.waitForFunction((count: number) => document.querySelectorAll('.session-tab').length >= count, 10, { + timeout: 5000, + }); }); console.log(`[bulk create] 10 sessions: ${timing.toFixed(0)}ms`); @@ -383,7 +403,7 @@ describe('Bulk tab operations', () => { await page.waitForFunction( (id: string) => document.querySelector(`.session-tab[data-id="${id}"]`)?.classList.contains('active'), targetId, - { timeout: 2000 }, + { timeout: 2000 } ); }); timings.push(timing); @@ -497,7 +517,10 @@ describe('Settings modal performance', () => { it('closes within threshold', async () => { // Make sure it's open - const isOpen = await page.locator('#appSettingsModal.active').isVisible().catch(() => false); + const isOpen = await page + .locator('#appSettingsModal.active') + .isVisible() + .catch(() => false); if (!isOpen) { await page.locator('.btn-settings').click(); await page.waitForSelector('#appSettingsModal.active', { timeout: 2000 }); @@ -506,10 +529,9 @@ describe('Settings modal performance', () => { const timing = await measure(async () => { // Press Escape to close await page.keyboard.press('Escape'); - await page.waitForFunction( - () => !document.querySelector('#appSettingsModal')?.classList.contains('active'), - { timeout: 2000 }, - ); + await page.waitForFunction(() => !document.querySelector('#appSettingsModal')?.classList.contains('active'), { + timeout: 2000, + }); }); console.log(`[settings] close: ${timing.toFixed(0)}ms`); @@ -528,10 +550,9 @@ describe('Settings modal performance', () => { // Close const closeTime = await measure(async () => { await page.keyboard.press('Escape'); - await page.waitForFunction( - () => !document.querySelector('#appSettingsModal')?.classList.contains('active'), - { timeout: 2000 }, - ); + await page.waitForFunction(() => !document.querySelector('#appSettingsModal')?.classList.contains('active'), { + timeout: 2000, + }); }); timings.push(openTime + closeTime); } @@ -575,7 +596,10 @@ describe('Session options modal performance', () => { it('tab switching within modal is instant', async () => { // Ensure modal is open - const isOpen = await page.locator('#sessionOptionsModal.active').isVisible().catch(() => false); + const isOpen = await page + .locator('#sessionOptionsModal.active') + .isVisible() + .catch(() => false); if (!isOpen) { await page.locator(`.session-tab[data-id="${sessionId}"] .tab-gear`).click(); await page.waitForSelector('#sessionOptionsModal.active', { timeout: 2000 }); @@ -590,7 +614,7 @@ describe('Session options modal performance', () => { await page.waitForFunction( (t: string) => document.querySelector(`[data-tab="${t}"]`)?.classList.contains('active'), tab, - { timeout: 1000 }, + { timeout: 1000 } ); }); timings.push(timing); @@ -634,27 +658,32 @@ describe('Subagent window simulation', () => { }, sessionId); const timing = await measure(async () => { - await page.evaluate(({ agentId, cSessionId }: { agentId: string; cSessionId: string }) => { - const app = (window as unknown as { - app: { - subagents: Map>; - openSubagentWindow: (id: string) => void; - } - }).app; - // Inject fake agent data - app.subagents.set(agentId, { - agentId, - sessionId: cSessionId, - status: 'active', - description: 'Performance test agent', - startedAt: Date.now(), - lastActivityAt: Date.now(), - toolCallCount: 0, - entryCount: 0, - fileSize: 0, - }); - app.openSubagentWindow(agentId); - }, { agentId: 'perf-agent-1', cSessionId: claudeSessionId }); + await page.evaluate( + ({ agentId, cSessionId }: { agentId: string; cSessionId: string }) => { + const app = ( + window as unknown as { + app: { + subagents: Map>; + openSubagentWindow: (id: string) => void; + }; + } + ).app; + // Inject fake agent data + app.subagents.set(agentId, { + agentId, + sessionId: cSessionId, + status: 'active', + description: 'Performance test agent', + startedAt: Date.now(), + lastActivityAt: Date.now(), + toolCallCount: 0, + entryCount: 0, + fileSize: 0, + }); + app.openSubagentWindow(agentId); + }, + { agentId: 'perf-agent-1', cSessionId: claudeSessionId } + ); await page.waitForSelector('.subagent-window', { timeout: 3000 }); }); @@ -679,7 +708,7 @@ describe('Subagent window simulation', () => { const el = document.getElementById('subagent-window-perf-agent-1'); return el && el.style.display === 'none'; }, - { timeout: 2000 }, + { timeout: 2000 } ); }); @@ -698,33 +727,35 @@ describe('Subagent window simulation', () => { const timing = await measure(async () => { for (let i = 0; i < 5; i++) { - await page.evaluate(({ idx, cSessionId }: { idx: number; cSessionId: string }) => { - const agentId = `perf-multi-agent-${idx}`; - const app = (window as unknown as { - app: { - subagents: Map>; - openSubagentWindow: (id: string) => void; - } - }).app; - app.subagents.set(agentId, { - agentId, - sessionId: cSessionId, - status: 'active', - description: `Perf agent ${idx}`, - startedAt: Date.now(), - lastActivityAt: Date.now(), - toolCallCount: 0, - entryCount: 0, - fileSize: 0, - }); - app.openSubagentWindow(agentId); - }, { idx: i, cSessionId: claudeSessionId }); + await page.evaluate( + ({ idx, cSessionId }: { idx: number; cSessionId: string }) => { + const agentId = `perf-multi-agent-${idx}`; + const app = ( + window as unknown as { + app: { + subagents: Map>; + openSubagentWindow: (id: string) => void; + }; + } + ).app; + app.subagents.set(agentId, { + agentId, + sessionId: cSessionId, + status: 'active', + description: `Perf agent ${idx}`, + startedAt: Date.now(), + lastActivityAt: Date.now(), + toolCallCount: 0, + entryCount: 0, + fileSize: 0, + }); + app.openSubagentWindow(agentId); + }, + { idx: i, cSessionId: claudeSessionId } + ); } // Wait for all 5 - await page.waitForFunction( - () => document.querySelectorAll('.subagent-window').length >= 5, - { timeout: 5000 }, - ); + await page.waitForFunction(() => document.querySelectorAll('.subagent-window').length >= 5, { timeout: 5000 }); }); const windowCount = await page.locator('.subagent-window').count(); @@ -772,7 +803,7 @@ describe('SSE event throughput', () => { await page.waitForFunction( (count: number) => document.querySelectorAll('.session-tab').length >= count, sessionIds.length, - { timeout: 5000 }, + { timeout: 5000 } ); const elapsed = performance.now() - start; @@ -797,7 +828,7 @@ describe('SSE event throughput', () => { await page.waitForFunction( (count: number) => document.querySelectorAll('.session-tab').length >= count, sessionIds.length, - { timeout: 5000 }, + { timeout: 5000 } ); const start = performance.now(); @@ -813,7 +844,7 @@ describe('SSE event throughput', () => { } return true; }, - { timeout: 5000 }, + { timeout: 5000 } ); const elapsed = performance.now() - start; @@ -880,11 +911,9 @@ describe('Memory usage under load', () => { const id = await createSession(page, `perf-mem-${i}`); sessionIds.push(id); } - await page.waitForFunction( - (count: number) => document.querySelectorAll('.session-tab').length >= count, - 10, - { timeout: 5000 }, - ); + await page.waitForFunction((count: number) => document.querySelectorAll('.session-tab').length >= count, 10, { + timeout: 5000, + }); // Switch through all tabs for (const id of sessionIds) { @@ -895,10 +924,13 @@ describe('Memory usage under load', () => { const heapAfter = await getHeapMB(page); const heapGrowth = heapAfter - heapBefore; - console.log(`[memory] before: ${heapBefore.toFixed(1)}MB, after: ${heapAfter.toFixed(1)}MB, growth: ${heapGrowth.toFixed(1)}MB`); + console.log( + `[memory] before: ${heapBefore.toFixed(1)}MB, after: ${heapAfter.toFixed(1)}MB, growth: ${heapGrowth.toFixed(1)}MB` + ); // Heap should stay under absolute limit - if (heapAfter > 0) { // memory API may not be available + if (heapAfter > 0) { + // memory API may not be available expect(heapAfter).toBeLessThan(THRESHOLDS.MEMORY_HEAP_MB); } }); diff --git a/test/push-payload-host-title.test.ts b/test/push-payload-host-title.test.ts index 2ce1e7f5..b5c39d85 100644 --- a/test/push-payload-host-title.test.ts +++ b/test/push-payload-host-title.test.ts @@ -47,7 +47,8 @@ interface PushPayload { function makeServerWithHost(host: string): WebServer { // Constructor only assigns fields — no network/disk activity until start(). - const server = new WebServer(0, false, true, host); + // 4th arg is the bind host; the title hostname is the 5th arg. + const server = new WebServer(0, false, true, '127.0.0.1', host); // Stub push store: one subscription with all events enabled. const fakeSub = { endpoint: 'https://push.example.com/abc', diff --git a/test/ralph-integration.test.ts b/test/ralph-integration.test.ts index d84b9a6c..27521066 100644 --- a/test/ralph-integration.test.ts +++ b/test/ralph-integration.test.ts @@ -106,7 +106,7 @@ describe('Ralph Integration Tests', () => { const res = await fetch(`${baseUrl}/api/sessions/non-existent-id`); const data = await res.json(); - expect(res.status).toBe(200); + expect(res.status).toBe(404); expect(data.success).toBe(false); expect(data.errorCode).toBe('NOT_FOUND'); }); @@ -136,7 +136,8 @@ describe('Ralph Integration Tests', () => { // Verify session is gone const getRes = await fetch(`${baseUrl}/api/sessions/${sessionId}`); const getData = await getRes.json(); - expect(getData.error).toBe('Session not found'); + expect(getRes.status).toBe(404); + expect(getData.error).toContain('not found'); }); it('should create shell session', async () => { @@ -191,7 +192,7 @@ describe('Ralph Integration Tests', () => { const res = await fetch(`${baseUrl}/api/sessions/fake-session/ralph-state`); const data = await res.json(); - expect(res.status).toBe(200); + expect(res.status).toBe(404); expect(data.success).toBe(false); expect(data.error).toContain('not found'); }); @@ -359,7 +360,7 @@ describe('Ralph Integration Tests', () => { }); const data = await res.json(); - expect(res.status).toBe(200); + expect(res.status).toBe(400); expect(data.success).toBe(false); expect(data.errorCode).toBe('INVALID_INPUT'); }); @@ -372,7 +373,7 @@ describe('Ralph Integration Tests', () => { }); const data = await res.json(); - expect(res.status).toBe(200); + expect(res.status).toBe(404); expect(data.success).toBe(false); expect(data.errorCode).toBe('NOT_FOUND'); }); @@ -395,7 +396,7 @@ describe('Ralph Integration Tests', () => { createdSessions.push(createData.sessionId); // Wait for session to be ready - await new Promise(r => setTimeout(r, 200)); + await new Promise((r) => setTimeout(r, 200)); const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/resize`, { method: 'POST', @@ -422,7 +423,7 @@ describe('Ralph Integration Tests', () => { createdSessions.push(createData.sessionId); // Wait for session to be ready - await new Promise(r => setTimeout(r, 200)); + await new Promise((r) => setTimeout(r, 200)); const res = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/resize`, { method: 'POST', @@ -431,7 +432,7 @@ describe('Ralph Integration Tests', () => { }); const data = await res.json(); - expect(res.status).toBe(200); + expect(res.status).toBe(400); expect(data.success).toBe(false); expect(data.errorCode).toBe('INVALID_INPUT'); }); @@ -472,7 +473,7 @@ describe('Ralph Integration Tests', () => { }); const data = await res.json(); - expect(res.status).toBe(200); + expect(res.status).toBe(404); expect(data.success).toBe(false); expect(data.errorCode).toBe('NOT_FOUND'); }); @@ -497,7 +498,7 @@ describe('Ralph Integration Tests', () => { }); const data = await res.json(); - expect(res.status).toBe(200); + expect(res.status).toBe(400); expect(data.success).toBe(false); expect(data.errorCode).toBe('INVALID_INPUT'); }); @@ -536,7 +537,7 @@ describe('Ralph Integration Tests', () => { }); const data = await res.json(); - expect(res.status).toBe(200); + expect(res.status).toBe(404); expect(data.success).toBe(false); expect(data.errorCode).toBe('NOT_FOUND'); }); @@ -561,7 +562,7 @@ describe('Ralph Integration Tests', () => { }); const data = await res.json(); - expect(res.status).toBe(200); + expect(res.status).toBe(400); expect(data.success).toBe(false); expect(data.errorCode).toBe('INVALID_INPUT'); }); @@ -626,7 +627,7 @@ describe('Ralph Integration Tests', () => { }); const data = await res.json(); - expect(res.status).toBe(200); + expect(res.status).toBe(404); expect(data.success).toBe(false); expect(data.errorCode).toBe('NOT_FOUND'); }); @@ -907,7 +908,7 @@ describe('Ralph Integration Tests', () => { }); const data = await res.json(); - expect(res.status).toBe(200); + expect(res.status).toBe(404); expect(data.success).toBe(false); expect(data.errorCode).toBe('NOT_FOUND'); }); @@ -991,7 +992,7 @@ describe('Ralph Integration Tests', () => { const res = await fetch(`${baseUrl}/api/sessions/fake-session/output`); const data = await res.json(); - expect(res.status).toBe(200); + expect(res.status).toBe(404); expect(data.success).toBe(false); expect(data.errorCode).toBe('NOT_FOUND'); }); @@ -1021,7 +1022,7 @@ describe('Ralph Integration Tests', () => { const res = await fetch(`${baseUrl}/api/sessions/fake-session/terminal`); const data = await res.json(); - expect(res.status).toBe(200); + expect(res.status).toBe(404); expect(data.success).toBe(false); expect(data.errorCode).toBe('NOT_FOUND'); }); diff --git a/test/render-index-html.test.ts b/test/render-index-html.test.ts new file mode 100644 index 00000000..2ae4b6ed --- /dev/null +++ b/test/render-index-html.test.ts @@ -0,0 +1,96 @@ +/** + * WebServer.renderIndexHtml — server-side gating of the index shell: + * - multi-monitor button reveal (stable class-marker, not brittle copy match) + * - solo (/session/:id) global injection + escaping, and settings skipped + * - gesture overlay availability vs. enablement (CODEMAN_GESTURE + setting) + * - settings read FRESH so a post-save reload doesn't render stale state + * + * WebServer's constructor only assigns fields (no port bind), so we construct it + * directly, swap in a tiny indexHtmlTemplate, and stub readSettings to avoid disk. + * + * Port: N/A (no server start). + */ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { WebServer } from '../src/web/server.js'; + +const TEMPLATE = [ + '', + 'Codeman', + '', + '', + '', + '', +].join('\n'); + +function makeServer(settings: Record = {}) { + const server = new WebServer(0, false, true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (server as any).indexHtmlTemplate = TEMPLATE; + const readSettings = vi.fn(async () => settings); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (server as any).readSettings = readSettings; + return { server, readSettings }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const render = (server: WebServer, solo?: string): Promise => (server as any).renderIndexHtml(solo); + +const ORIG_GESTURE = process.env.CODEMAN_GESTURE; +afterEach(() => { + if (ORIG_GESTURE === undefined) delete process.env.CODEMAN_GESTURE; + else process.env.CODEMAN_GESTURE = ORIG_GESTURE; +}); + +describe('WebServer.renderIndexHtml', () => { + it('keeps the multi-monitor button hidden by default and reads settings FRESH', async () => { + const { server, readSettings } = makeServer({}); + const html = await render(server); + expect(html).toContain('btn-multimonitor--hidden'); + // forceFresh=true — fixes the post-save reload race against the 2s cache. + expect(readSettings).toHaveBeenCalledWith(true); + }); + + it('reveals the multi-monitor button when showMultiMonitorButton is set', async () => { + const { server } = makeServer({ showMultiMonitorButton: true }); + const html = await render(server); + expect(html).not.toContain('btn-multimonitor--hidden'); + expect(html).toContain('btn-multimonitor"'); // class list still present, only the marker stripped + }); + + it('injects the solo global and skips settings for a /session/:id window', async () => { + const { server, readSettings } = makeServer({ showMultiMonitorButton: true }); + const html = await render(server, 'sess-123'); + expect(html).toContain('window.__CODEMAN_SOLO__="sess-123"'); + expect(readSettings).not.toHaveBeenCalled(); + // Solo skips settings, so the button is NOT revealed even though the setting is on. + expect(html).toContain('btn-multimonitor--hidden'); + }); + + it('escapes the solo id so it cannot break out of the inline '); + expect(html).not.toContain(''); + expect(html).toContain('\\u003c'); + }); + + it('exposes gesture availability but injects the bundle only when enabled', async () => { + process.env.CODEMAN_GESTURE = '1'; + let { server } = makeServer({ gestureControlEnabled: false }); + let html = await render(server); + expect(html).toContain('window.__codemanGestureAvailable=true'); + expect(html).not.toContain('gesture-codeman.js'); + + ({ server } = makeServer({ gestureControlEnabled: true })); + html = await render(server); + expect(html).toContain('window.__codemanGestureAvailable=true'); + expect(html).toContain('gesture-codeman.js'); + }); + + it('does not expose gesture at all when CODEMAN_GESTURE is unset', async () => { + delete process.env.CODEMAN_GESTURE; + const { server } = makeServer({ gestureControlEnabled: true }); + const html = await render(server); + expect(html).not.toContain('__codemanGestureAvailable'); + expect(html).not.toContain('gesture-codeman.js'); + }); +}); diff --git a/test/routes/file-routes.test.ts b/test/routes/file-routes.test.ts index bb657733..903dc7f3 100644 --- a/test/routes/file-routes.test.ts +++ b/test/routes/file-routes.test.ts @@ -305,6 +305,22 @@ describe('file-routes', () => { expect(res.headers['content-type']).toBe('image/png'); }); + it('serves workspace SVG as an untrusted attachment instead of inline image/svg+xml', async () => { + const content = Buffer.from(''); + mockedReadFile.mockResolvedValue(content as never); + mockedStat.mockResolvedValue({ size: content.length } as never); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=malicious.svg`, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('application/octet-stream'); + expect(res.headers['content-disposition']).toContain('attachment; filename="malicious.svg"'); + expect(res.headers['x-content-type-options']).toBe('nosniff'); + }); + it('rejects path traversal in raw file serving', async () => { mockedRealpathSync.mockReturnValue('/etc/shadow' as never); @@ -363,4 +379,63 @@ describe('file-routes', () => { expect(body.success).toBe(false); }); }); + + // ========== GET /api/download ========== + + describe('GET /api/download', () => { + it('requires a sessionId to scope downloads', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: `/api/download?path=${encodeURIComponent('/tmp/test-workdir/report.txt')}`, + }); + + expect(res.statusCode).toBe(400); + }); + + it('downloads files scoped to the session working directory', async () => { + const content = Buffer.from('download content'); + mockedReadFile.mockResolvedValue(content as never); + mockedStat.mockResolvedValue({ size: content.length, isFile: () => true } as never); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/download?sessionId=${harness.ctx._sessionId}&path=report.txt`, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-disposition']).toContain('filename="report.txt"'); + expect(res.body).toBe('download content'); + }); + + it('rejects absolute paths outside the session working directory', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: `/api/download?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent('/var/log/app.log')}`, + }); + + expect(res.statusCode).toBe(404); + }); + + it('rejects symlink targets that escape the session working directory', async () => { + mockedRealpathSync.mockReturnValue('/tmp/outside-workdir/link.log' as never); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/download?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent( + '/tmp/test-workdir/link.log' + )}`, + }); + + expect(res.statusCode).toBe(404); + }); + + it('blocks sensitive files even when they are inside the session working directory', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: `/api/download?sessionId=${harness.ctx._sessionId}&path=.env`, + }); + + expect(res.statusCode).toBe(403); + }); + }); }); diff --git a/test/routes/system-span-displays.test.ts b/test/routes/system-span-displays.test.ts new file mode 100644 index 00000000..9a5359e5 --- /dev/null +++ b/test/routes/system-span-displays.test.ts @@ -0,0 +1,76 @@ +/** + * POST /api/system/span-displays (multi-monitor launcher) + resolveSpanUrl. + * + * The route shells out to scripts/span-codeman.sh, so we mock child_process.spawn + * to avoid actually opening a browser (and to assert the sanitized URL passed to + * it). process.platform is overridden per-case so the macOS-only guard is tested + * deterministically regardless of where the suite runs. + * + * Port: N/A (app.inject). + */ +import { describe, it, expect, afterEach, vi } from 'vitest'; + +const spawnMock = vi.hoisted(() => vi.fn(() => ({ on: vi.fn(), unref: vi.fn() }))); +vi.mock('node:child_process', async (orig) => { + const actual = await orig(); + return { ...actual, spawn: spawnMock }; +}); + +import { createRouteTestHarness } from './_route-test-utils.js'; +import { registerSystemRoutes, resolveSpanUrl } from '../../src/web/routes/system-routes.js'; + +const REAL_PLATFORM = process.platform; +function setPlatform(p: NodeJS.Platform) { + Object.defineProperty(process, 'platform', { value: p, configurable: true }); +} +afterEach(() => { + setPlatform(REAL_PLATFORM); + spawnMock.mockClear(); +}); + +describe('resolveSpanUrl', () => { + it('takes a digits-only port from the Host header, pinned to localhost', () => { + expect(resolveSpanUrl('localhost:5000')).toBe('http://localhost:5000'); + // Hostname is discarded — always localhost (same machine). + expect(resolveSpanUrl('attacker.example.com:3000')).toBe('http://localhost:3000'); + }); + + it('falls back to the default port for missing / non-numeric ports', () => { + expect(resolveSpanUrl(undefined)).toBe('http://localhost:3000'); + expect(resolveSpanUrl('localhost')).toBe('http://localhost:3000'); + expect(resolveSpanUrl('localhost:99;rm -rf /')).toBe('http://localhost:3000'); + expect(resolveSpanUrl('localhost:80abc')).toBe('http://localhost:3000'); + expect(resolveSpanUrl('x', '5000')).toBe('http://localhost:5000'); + }); +}); + +describe('POST /api/system/span-displays', () => { + it('returns 400 (macOS-only) on non-darwin and never spawns', async () => { + setPlatform('linux'); + const { app } = await createRouteTestHarness(registerSystemRoutes); + const res = await app.inject({ method: 'POST', url: '/api/system/span-displays' }); + expect(res.statusCode).toBe(400); + expect(res.json().success).toBe(false); + expect(res.json().error).toMatch(/macOS/i); + expect(spawnMock).not.toHaveBeenCalled(); + await app.close(); + }); + + it('spawns the launcher with the sanitized localhost URL on darwin', async () => { + setPlatform('darwin'); + const { app } = await createRouteTestHarness(registerSystemRoutes); + const res = await app.inject({ + method: 'POST', + url: '/api/system/span-displays', + headers: { host: 'localhost:5000' }, + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ success: true, url: 'http://localhost:5000' }); + expect(spawnMock).toHaveBeenCalledTimes(1); + const [cmd, args] = spawnMock.mock.calls[0] as [string, string[]]; + expect(cmd).toBe('bash'); + expect(args[0]).toMatch(/span-codeman\.sh$/); + expect(args[1]).toBe('http://localhost:5000'); + await app.close(); + }); +}); diff --git a/test/server-index-title.test.ts b/test/server-index-title.test.ts index 85deba6c..76cd4f02 100644 --- a/test/server-index-title.test.ts +++ b/test/server-index-title.test.ts @@ -32,36 +32,38 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const indexHtmlPath = join(__dirname, '..', 'src', 'web', 'public', 'index.html'); const rawTemplate = readFileSync(indexHtmlPath, 'utf-8'); -function render(host?: string): string { - const server = new WebServer(0, false, true, host); - return (server as unknown as { renderIndexHtml: () => string }).renderIndexHtml(); +async function render(host?: string): Promise { + // 4th arg is the bind host; the title hostname is the 5th arg. + const server = new WebServer(0, false, true, '127.0.0.1', host); + // renderIndexHtml is async (it reads settings.json for the gesture bundle). + return (server as unknown as { renderIndexHtml: () => Promise }).renderIndexHtml(); } describe('WebServer index.html templating (#82)', () => { - it('substitutes the bare <title>Codeman with codeman:', () => { - const html = render('laptop'); + it('substitutes the bare Codeman with codeman:', async () => { + const html = await render('laptop'); expect(html).toContain('codeman:laptop'); expect(html).not.toContain('Codeman'); }); - it('defaults to os.hostname() when no titleHostname is supplied', () => { - const html = render(); + it('defaults to os.hostname() when no titleHostname is supplied', async () => { + const html = await render(); const expected = `codeman:${osHostname()}`; expect(html).toContain(expected); }); - it('treats an empty-string titleHostname as "not supplied" and falls back to os.hostname()', () => { + it('treats an empty-string titleHostname as "not supplied" and falls back to os.hostname()', async () => { // CLI normally guarantees a non-empty string, but the constructor's // `titleHostname || getHostname()` guard makes empty fall through — // pin that behavior so a future refactor doesn't accidentally ship // a `codeman:` to users. - const html = render(''); + const html = await render(''); expect(html).toMatch(/codeman:.+<\/title>/); expect(html).not.toContain('<title>codeman:'); }); - it('HTML-escapes < > & in the hostname so it cannot break out of the title tag', () => { - const html = render(''); + it('HTML-escapes < > & in the hostname so it cannot break out of the title tag', async () => { + const html = await render(''); expect(html).toContain('codeman:<script>alert(1)</script>'); // The raw closing from the injected payload must NOT appear // outside the actual title element — escape-then-substitute prevents @@ -69,16 +71,18 @@ describe('WebServer index.html templating (#82)', () => { expect(html).not.toContain('<script>alert(1)</script>'); }); - it('escapes an ampersand without double-encoding existing entities', () => { + it('escapes an ampersand without double-encoding existing entities', async () => { // The escaper replaces & first, then < and >. A hostname that already // contains a literal `&` should render as `&` once, not `&amp;`. - const html = render('a&b'); + const html = await render('a&b'); expect(html).toContain('codeman:a&b'); expect(html).not.toContain('&amp;'); }); - it('only substitutes the tag — the rest of the template is byte-for-byte identical', () => { - const html = render('laptop'); + it('only substitutes the <title> tag — the rest of the template is identical (modulo asset cache-busting)', async () => { + // renderIndexHtml also appends ?v=<mtime> cache-bust params to same-origin + // .js/.css refs; strip them so the title remains the only other change. + const html = (await render('laptop')).replace(/(\.(?:js|css))\?v=[^"]*/g, '$1'); const beforeTitle = rawTemplate.split('<title>Codeman')[0]; const afterTitle = rawTemplate.split('Codeman')[1]; expect(html.startsWith(beforeTitle)).toBe(true); @@ -88,8 +92,8 @@ describe('WebServer index.html templating (#82)', () => { expect(html.length - rawTemplate.length).toBe(expectedDelta); }); - it('replaces the <title> placeholder exactly once', () => { - const html = render('laptop'); + it('replaces the <title> placeholder exactly once', async () => { + const html = await render('laptop'); // Defense against a future regression where the template gains a // second `<title>Codeman` (e.g. inside a