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/CLAUDE.md b/CLAUDE.md index a48358c5..1414ac36 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), opt-in via CODEMAN_GESTURE=1. The bundle is served + // same-origin from /gesture/ so 'self' covers it; CSP is widened to match in + // registerSecurityHeaders under the same flag. + if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') { + 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 +1166,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'); @@ -1112,7 +1190,7 @@ export class WebServer extends EventEmitter { if (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; @@ -1619,7 +1697,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 +1718,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 +1736,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 +1754,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); 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/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(); + }); +});