From dc63d1f1a6a3178bb5ea9c7ffb846211eb0a9ba6 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 14 Jun 2026 23:59:40 +0200 Subject: [PATCH] tools: harden real-overview screenshot capture + document DSF/cache gotchas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/capture-real-overview.mjs: - Default deviceScaleFactor to 1 (DSF=2 makes xterm's headless WebGL renderer draw console glyphs at ~2x while reporting nominal cell dims — invisible to cols/cell measurement, only the pixels reveal it; HTML chrome is unaffected so only the terminal font looks oversized) - Mint a unique timestamped filename per run so a viewer/HTTP cache can't shadow a fresh capture with a stale render of a fixed path - Seed per-device localStorage (skin, codeman-font-size, codeman-app-settings) so the capture reflects a real device: plan-usage chip shown (per-device key, deleted from server payload), side panels closed for a full-width terminal - Support prod's self-signed HTTPS (ignoreHTTPSErrors), env-configurable viewport CLAUDE.md: - Document the DSF=1 / unique-filename screenshot gotcha (incl. the real Codeman-side immutable-static-asset cache footgun) - Add the sanitize-html.js infra module (DOMPurify mXSS allowlist, COD-56) to the frontend module list and load order (was missing) Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 5 +- scripts/capture-real-overview.mjs | 147 ++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 scripts/capture-real-overview.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 4fa8ea9d..753c7e86 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,6 +108,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph - **`xterm-zerolag-input` is single-source — edit the package, then rebuild the bundle** — the local-echo overlay source lives ONLY in `packages/xterm-zerolag-input/src/` (`zerolag-input-addon.ts`; also published to npm as a standalone library — see README "Published Packages"). It is bundled (esbuild → IIFE, with appended `window.LocalEchoOverlay` aliases) into the **gitignored** `src/web/public/vendor/xterm-zerolag-input.js` by `scripts/postinstall.js` (for dev/`tsx`) and into `dist/.../vendor/` by `scripts/build.mjs:50` (for prod). `app.js` only **consumes** it via `new LocalEchoOverlay(terminal)` — there is NO inline copy to keep in sync. So: change behavior in the package source, then re-run the bundle step (`npm install` reruns postinstall; `npm run build` for prod); **never hand-edit `app.js` for overlay behavior or commit the gitignored vendor bundle**. A public-API break in the package still warrants a separate `xterm-zerolag-input` version bump in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`. - **Default bind is loopback-only; non-loopback without a password starts but warns** — since COD-29 (PR #107) the web server defaults to `--host 127.0.0.1` (was `0.0.0.0`). As of **0.9.0** binding a non-loopback host (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` **no longer refuses to start — it starts and prints a loud warning** listing the fixes (set `CODEMAN_PASSWORD`, bind loopback + tunnel/`tailscale serve`, or `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` to acknowledge → terser note). Host classification is `isLoopbackBindHost()` in `network-auth-policy.ts`; the warn-vs-start logic is in `server.ts` `start()`; flags wired in `cli.ts`. ⚠️ Operational note: the production systemd unit runs `node dist/index.js web --https` with no `--host`, so it binds **localhost only** — reach it remotely via `tailscale serve`/tunnel to `127.0.0.1`, or add `Environment=CODEMAN_HOST=0.0.0.0` + `Environment=CODEMAN_PASSWORD=…` to `~/.config/systemd/user/codeman-web.service`. A loopback bind is reachable through a same-host tunnel (cloudflared/tailscale → `127.0.0.1`) but NOT by a browser hitting the box's LAN IP. Auth user defaults to `admin`. **Full model: `docs/security-architecture.md`.** - **Instance isolation / multi-instance attach danger** — data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts` (`getDataDir()`/`dataPath()`/`DEFAULT_TMUX_SOCKET`). ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions** (`tmux -L codeman attach-session …`), resizing/mutating them — `$HOME` isolation is NOT enough (tmux is system-global). To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes BOTH dir+socket: `~/.codeman-` + `-L codeman-`), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually. **`CODEMAN_INSTANCE` defaults to empty = the production layout (`~/.codeman`, `-L codeman`, port 3000)**, so this branch is safe to ship to master without disturbing existing installs. To run THIS beta alongside prod, launch with `scripts/run-beta.sh` (`CODEMAN_INSTANCE=beta` + `CODEMAN_PORT=5000`) — it never collides with prod's data dir/socket/port. Any new `~/.codeman/...` path MUST go through `dataPath()`, never `join(homedir(), '.codeman', …)`. +- **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — `scripts/capture-real-overview.mjs` (drives a live session in headless Chromium → overview PNG). Two traps, both observed 2026-06-14: **(1) DSF=2 doubles the console font.** xterm's WebGL renderer draws terminal glyphs at ~2× their nominal size under `deviceScaleFactor: 2`, while STILL reporting nominal cell dims (`terminal.cols`/`_renderService.dimensions.css.cell` say 8px/187cols — they lie), so it's invisible to any internal measurement and only the pixels reveal it. The HTML chrome (header/toolbar) is unaffected → ONLY the console font looks comically large. Default to **DSF=1** (script does); the image is 1× res but the font is true-to-browser. **(2) Stable filenames → stale renders.** Overwriting a fixed path (`claude-overview.png`) in place leaves OS image viewers (eog/feh) — and any HTTP client behind a long/`immutable` cache — showing the OLD render; the user reads it as "the fix didn't work". The script now mints a timestamped `claude-overview-.png` per run. ⚠️ This was a LOCAL image-viewer cache, NOT a Codeman serving bug: `file-routes` previews send `Cache-Control: no-cache` and `/api/screenshots/:name` sends none. The one real Codeman-side footgun: `server.ts` serves non-content-hashed static assets `public, max-age=31536000, immutable`, and `cacheBustAssets()` only rewrites `.js`/`.css` refs — a stable-named **image** referenced from public/ would go stale on overwrite. Reflect the per-device UI to match a real device when capturing: seed `localStorage` `codeman:skin`, `codeman-font-size`, and the desktop `codeman-app-settings` blob (the plan-usage chip is a per-device display key deleted from the server payload — a fresh browser hides it unless seeded; close side panels for a full-width terminal). **Import conventions**: Utils from `./utils`, types from `./types` (barrel), config from specific `./config/*` files. @@ -131,7 +132,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | **Attachments** | `src/attachment-registry.ts`, `src/attachment-magic.ts`, `src/session-attachment-history.ts`, `src/document-preview-cache.ts`, `src/document-thumbnailer.ts`, `src/document-conversion-limiter.ts`, `src/config/attachment-guard.ts` | See Key Patterns | | **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`, the CLAUDE.md scaffold generated into new cases) | | | **Web** | `src/web/server.ts` ★, `src/web/sse-events.ts`, `src/web/routes/*.ts` (16 route modules + barrel; `session-routes.ts` ★), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts`, `src/web/self-update.ts`, `src/web/plan-usage-latest.ts` | | -| **Frontend** | `src/web/public/app.js` (~3.9K lines, core) + 5 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 5 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | | +| **Frontend** | `src/web/public/app.js` (~3.9K lines, core) + 6 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`, `sanitize-html.js` — DOMPurify mXSS allowlist, COD-56) + 7 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 5 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | | | **Types** | `src/types/index.ts` (barrel) → 15 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts | ★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`. @@ -177,7 +178,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ### Frontend -Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `settings-ui.js`(10) → `panels-ui.js`(11) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `image-input.js`(16). `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). +Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `settings-ui.js`(10) → `panels-ui.js`(11) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `image-input.js`(16). `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). **Z-index layers**: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried; bug fixed in `b8cb467`), log viewers (2000), image popups (3000), local echo overlay (7). diff --git a/scripts/capture-real-overview.mjs b/scripts/capture-real-overview.mjs new file mode 100644 index 00000000..3a5f6b14 --- /dev/null +++ b/scripts/capture-real-overview.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node +/** + * capture-real-overview.mjs + * + * Captures a REAL claude-overview screenshot from a LIVE Codeman server + * (no mock injection). Drive a real session to do real work, then run: + * + * SID= BASE=http://localhost:5000 OUT=screenshots-real \ + * node scripts/capture-real-overview.mjs + * + * Skin defaults to daylight-blue (prod default) via the localStorage pre-paint + * contract in index.html. Output: /claude-overview.png at 1280x720 (DSF 2). + */ +import { chromium } from 'playwright'; +import { mkdirSync } from 'fs'; +import { join } from 'path'; + +const SID = process.env.SID; +const BASE = process.env.BASE || 'http://localhost:5000'; +const OUT = process.env.OUT || 'screenshots-real'; +const SKIN = process.env.SKIN || 'daylight-blue'; +// Unique filename per run (timestamped) so a viewer holding an old render of a +// fixed path can never shadow a fresh capture. Override with NAME=… if needed. +const STAMP = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19); +const NAME = process.env.NAME || `claude-overview-${STAMP}.png`; +const VIEWPORT = { width: Number(process.env.VW || 1512), height: Number(process.env.VH || 812) }; +// IMPORTANT: default deviceScaleFactor is 1, NOT 2. xterm's WebGL renderer in +// headless Chromium draws terminal glyphs at ~2× their nominal size when DSF=2 +// (while still reporting nominal 8px cell dims internally, so it can't be caught +// by measuring terminal.cols/cell — only the pixels reveal it). The HTML chrome +// is unaffected, so DSF=2 makes ONLY the console font look comically large. DSF=1 +// renders the console at its true size, matching a real (non-headless) browser. +const DSF = Number(process.env.DSF || 1); + +if (!SID) { + console.error('SID env var required (the live session id to screenshot)'); + process.exit(1); +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const main = async () => { + mkdirSync(OUT, { recursive: true }); + const browser = await chromium.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'], + }); + const context = await browser.newContext({ + viewport: VIEWPORT, + deviceScaleFactor: DSF, + ignoreHTTPSErrors: BASE.startsWith('https'), + }); + const page = await context.newPage(); + page.setDefaultTimeout(30000); + + // Force the skin before any page script runs (pre-paint contract), and + // seed the PER-DEVICE display blob so the capture reflects what prod actually + // shows on the user's real device — notably the plan-usage chip, which is a + // per-device setting (default OFF) deleted from the server payload, so a fresh + // browser would otherwise hide it. PLAN_USAGE=0 disables. + const PLAN_USAGE = process.env.PLAN_USAGE !== '0'; + // Terminal console font size. App default is 14px; a fresh headless browser has + // no saved codeman-font-size, so it renders at 14 — much larger than a real + // device where the console has been zoomed down. Seed a smaller value (clamped + // to the app's [10,24] range) so the console font looks normal in the capture. + const FONT = Math.max(10, Math.min(24, Number(process.env.FONT || 14))); + await page.addInitScript( + ([skin, planUsage, font]) => { + try { + localStorage.setItem('codeman:skin', skin); + localStorage.setItem('codeman-font-size', String(font)); + // Desktop app-settings blob (settings-ui.js getSettingsStorageKey()). + // Present these display keys explicitly so the server merge won't seed + // side panels open (display keys only seed from server when absent from + // localStorage). Matches the clean full-width-terminal reference look. + const blob = { + skin, + showFileBrowser: false, + showMonitor: false, + showSubagents: false, + showProjectInsights: false, + }; + if (planUsage) blob.showPlanUsageLimits = true; + localStorage.setItem('codeman-app-settings', JSON.stringify(blob)); + } catch { + /* ignore */ + } + }, + [SKIN, PLAN_USAGE, FONT] + ); + + console.log(`Loading ${BASE} ...`); + await page.goto(BASE, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.app && window.app.terminal, { timeout: 20000 }); + await sleep(1500); + + console.log(`Selecting session ${SID} ...`); + await page.evaluate((sid) => window.app.selectSession(sid), SID); + + // Let the terminal buffer stream in + xterm render + any Ink redraw settle. + await sleep(2000); + + // Force a clean fit (avoids capturing a transient pre-fit frame where the + // terminal renders at the wrong column count) and re-apply per-device header + // visibility so the seeded plan-usage chip is shown. + await page.evaluate((font) => { + // Force the console font explicitly (setFontSize also re-fits) in case + // loadFontSize didn't pick up the seeded value before the session rendered. + try { + if (window.app.setFontSize) window.app.setFontSize(font); + else window.app.terminal.options.fontSize = font; + } catch {} + try { + window.app.fitAddon && window.app.fitAddon.fit(); + } catch {} + try { + window.dispatchEvent(new Event('resize')); + } catch {} + try { + window.app.applyHeaderVisibilitySettings && window.app.applyHeaderVisibilitySettings(); + } catch {} + }, FONT); + await sleep(3000); + + // Optionally scroll the terminal up to frame the rich tool-call region + // (Read/Write/Bash + green test results) instead of the trailing summary. + const SCROLL = Number(process.env.SCROLL || 0); + if (SCROLL) { + await page.evaluate((n) => { + const t = window.app && window.app.terminal; + if (t && t.scrollLines) t.scrollLines(-n); + }, SCROLL); + await sleep(800); + } + + const outPath = join(OUT, NAME); + await page.screenshot({ path: outPath, fullPage: false }); + console.log(`Saved: ${outPath}`); + + await context.close(); + await browser.close(); +}; + +main().catch((e) => { + console.error('FATAL', e.message); + process.exit(1); +});