Compare commits

...
Author SHA1 Message Date
arkon 7884a37c55 chore: version packages 2026-05-19 12:11:44 +02:00
Ark0N ad89a97106 fix(renderer): WebGL longtask fallback hardening (#91)
Closes #89.

- _disposeWebGLObserver() called from both trip path and onContextLoss (fixes the leak)
- Thresholds (200ms/3/30s/5s/7d) hoisted to WEBGL_FALLBACK in constants.js
- Pure evaluateWebGLLongTaskTrip() helper + 9 Playwright tests (test/webgl-fallback.test.ts, port 3166)
2026-05-19 11:43:36 +02:00
arkonandClaude Opus 4.7 0600b7843e docs: add image-input.js to frontend module list in CLAUDE.md
PR #84 added `src/web/public/image-input.js` (clipboard paste + drag-drop)
but the CLAUDE.md frontend module table wasn't updated. Bumps the feature
modules count from 4 to 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:40:35 +02:00
arkonandClaude Opus 4.7 6d896c781e ci: add server boot smoke test
CI was running typecheck + lint + format only, which let plugin
registration regressions reach master — the @fastify/multipart conflict
in #90 crashed the server at startup but passed CI. The boot smoke
spawns the web server on a non-default port, polls /api/status for up
to 30s, and dumps the log on failure (either early exit or no-ready).

Catches: plugin registration conflicts, route registration errors,
import cycles, and any other failure between process start and
app.listen() resolving.

Auto-installs tmux on the runner since createMultiplexer() throws
without it (mux-factory.ts:17). ubuntu-latest already ships tmux, so
the install branch is normally a no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:41:42 +02:00
arkonandClaude Opus 4.7 930492058b fix(server): remove duplicate multipart parser conflicting with @fastify/multipart
#90 added @fastify/multipart, which registers its own multipart/form-data
content-type parser. Combined with the existing manual no-op parser in
setupRoutes() (originally there so /api/screenshots could read req.raw
directly), this raises "Content type parser 'multipart/form-data' already
present" at server boot and the process exits. CI did not catch it
because ci.yml runs typecheck + lint only.

@fastify/multipart's parser is a no-op marker (sets req[kMultipart] =
true and returns) and leaves the body on req.raw, so the legacy
/api/screenshots handler that reads req.raw directly keeps working
unchanged. The manual parser was redundant the moment the plugin was
registered.

Smoke-tested locally: server boots, /api/sessions/:id/paste-image
returns 200 / 403-CSRF / 415-magic-mismatch / 413-oversize / 429-rate
as designed; /api/screenshots upload still returns 200.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 10:37:26 +02:00
aakhter 101cee0cec security(paste-image): harden against 7 findings from PR #84 review (#90)
Hardens `/api/sessions/:id/paste-image` against the seven findings flagged in the dismissed security review on #84. Each commit addresses one finding.

- LOW: Collision-free filenames (`paste-${ts}-${rand4}${ext}`)
- MED: Symlink check on image dir (`lstat` + non-recursive mkdir + `O_EXCL|O_NOFOLLOW`)
- MED: Magic-byte validation (PNG/JPEG/GIF/WebP/BMP)
- HIGH: CSRF protection (Origin/Referer match req.host; non-browser clients send `X-Codeman-CSRF`)
- MED: Swap hand-rolled multipart parser to @fastify/multipart with `limits: { fileSize: 10MB, files: 1, fields: 4 }`
- MED: Rate limit (30/min per IP+session) + hourly GC of `paste-*` files older than 7d
- LOW: Use `terminal.paste(text)` instead of `sendInput(text)` so bracketed-paste markers survive

Co-authored-by: Aamer Akhter <aakhter@gmail.com>
2026-05-19 10:36:05 +02:00
13 changed files with 2676 additions and 85 deletions

No files matched your search

+26
View File
@@ -34,6 +34,32 @@ jobs:
- name: Format check
run: npm run format:check
- name: Server boot smoke test
run: |
set -u
if ! command -v tmux >/dev/null; then
sudo apt-get update -qq
sudo apt-get install -y tmux
fi
npx tsx src/index.ts web --port 3151 > /tmp/boot.log 2>&1 &
SERVER_PID=$!
trap "kill $SERVER_PID 2>/dev/null || true" EXIT
for i in $(seq 1 30); do
if curl -fsS http://localhost:3151/api/status -o /dev/null; then
echo "Server booted in ${i}s"
exit 0
fi
if ! kill -0 $SERVER_PID 2>/dev/null; then
echo "Server exited before becoming ready. Logs:"
cat /tmp/boot.log
exit 1
fi
sleep 1
done
echo "Server did not respond on /api/status within 30s. Logs:"
cat /tmp/boot.log
exit 1
# Note: The test suite is intentionally excluded from CI.
# Tests spawn real tmux sessions and require a full system environment.
# Run tests locally with: npx vitest run test/<file>.test.ts
+34
View File
@@ -1,5 +1,39 @@
# aicodeman
## 0.6.10
### Patch Changes
- ## Security: paste-image endpoint hardening (#90)
Addresses seven findings from the dismissed review of #84. Most exposed in tunneled deployments where `CODEMAN_PASSWORD` is set but the server is reachable beyond localhost.
- **CSRF protection** on `POST /api/sessions/:id/paste-image`. Requires `Origin`/`Referer` to match `req.host`; non-browser clients (no `Origin` and no `Referer`) must send `X-Codeman-CSRF`. Defeats cross-origin `<form enctype="multipart/form-data">` submits that would otherwise plant arbitrary bytes into the victim's `.claude-images/` while their session cookie is live.
- **Magic-byte validation** on uploaded images. Sniffs the first 12 bytes against PNG/JPEG/GIF/WebP/BMP signatures and rejects 415 on mismatch. Polyglot HTML-or-SVG-with-image-MIME no longer round-trips through the endpoint.
- **Symlink-safe writes** on `.claude-images/`. `lstat` before the write, non-recursive `mkdir`, `O_EXCL|O_NOFOLLOW` on file open. A `node_modules` postinstall (or the agent itself) planting `.claude-images -> ~/.ssh/` no longer redirects pastes outside `workingDir`.
- **Multipart parser swap** to `@fastify/multipart` with `limits: { fileSize: 10MB, files: 1, fields: 4 }`. Replaces a hand-rolled boundary scanner that matched the literal boundary anywhere in the body, hard-coded `\r\n` (silently corrupting LF-only clients), and had no part-count cap.
- **Rate limit + GC**: token-bucket (30/min per IP+session) and hourly GC of `paste-*` files older than 7 days from each live session's `.claude-images/`. New `paste-image-gc.ts` started/stopped from `WebServer.start/stop`.
- **Collision-free filenames**: `paste-${Date.now()}-${randomBytes(4)}${ext}`. Two tabs pasting in the same millisecond no longer silently last-write-wins.
- **Bracketed-paste preservation**: text-only paste in `image-input.js` now goes through `terminal.paste(text)` instead of `sendInput(text)`, so xterm preserves `CSI 200~ ... CSI 201~` markers — Claude Code uses them as part of its prompt-injection defenses.
## Fix: duplicate multipart parser conflict
Removed a duplicate multipart content-type parser left behind after the swap above. The duplicate registration conflicted with `@fastify/multipart`'s own parser; uploads now flow through the plugin exclusively.
## WebGL renderer auto-fallback hardening (#91)
Follow-ups on the longtask auto-fallback shipped in #83.
- `PerformanceObserver` is now disconnected on `onContextLoss` as well as on the trip path. Previously the observer outlived its disposed addon after a context loss, holding a closure reference over every longtask the page emitted.
- Thresholds (`200ms / 3 longtasks / 30s window / 5s grace / 7d sticky-disable`) are hoisted to `WEBGL_FALLBACK` in `constants.js`. No more inline literals.
- New `evaluateWebGLLongTaskTrip()` pure helper splits the rolling-window arithmetic from the `PerformanceObserver` callback so the trip math is unit-testable. New `test/webgl-fallback.test.ts` (9 tests, port 3166): trip inside window, no-trip when spread, sub-threshold filtering, stale-entry pruning, cumulative counting across batches, observer-dispose idempotency.
## CI: server boot smoke test
GitHub Actions now boots the server as a final step after typecheck/lint/format. Catches production-only ESM/CJS regressions that `tsx` masks in dev.
## Docs
`CLAUDE.md` frontend-module table updated to include `image-input.js` (overlooked when #84 landed).
## 0.6.9
### Patch Changes
+2 -2
View File
@@ -56,7 +56,7 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 0.6.9 (must match `package.json`)
**Version**: 0.6.10 (must match `package.json`)
## Project Overview
@@ -120,7 +120,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| **Infra** | `src/hooks-config.ts`, `src/push-store.ts`, `src/tunnel-manager.ts`, `src/image-watcher.ts`, `src/file-stream-manager.ts` | |
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/claude-md.ts` | |
| **Web** | `src/web/server.ts`, `src/web/sse-events.ts`, `src/web/routes/*.ts` (15 route modules + barrel), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts` | |
| **Frontend** | `src/web/public/app.js` (~2.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`) + 4 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `input-cjk.js`) + `sw.js` | |
| **Frontend** | `src/web/public/app.js` (~2.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` | |
| **Types** | `src/types/index.ts` (barrel) → 14 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
★ = Large file (>50KB). 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`.
+2033 -2
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "0.6.9",
"version": "0.6.10",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
@@ -52,6 +52,7 @@
"dependencies": {
"@fastify/compress": "^8.3.1",
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^8.0.0",
"@fastify/websocket": "^11.2.0",
"@xterm/addon-fit": "^0.11.0",
+69
View File
@@ -0,0 +1,69 @@
/**
* @fileoverview Periodic GC for paste-image files.
*
* Without cleanup, /api/sessions/:id/paste-image accumulates files indefinitely
* under {workingDir}/.claude-images/. The route only triggers cleanup on
* killMux=true session deletion, so long-lived sessions can fill disk under
* heavy pasting. This sweeper bounds disk use by deleting `paste-*` files
* older than MAX_AGE_MS from each live session's image dir on an interval.
*
* Conservative defaults — only files matching the `paste-` prefix are
* considered, and we lstat (not stat) so a planted symlink cannot escape the
* image dir.
*/
import fs from 'node:fs/promises';
import { join } from 'node:path';
import type { SessionPort } from './ports/index.js';
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const SWEEP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
const INITIAL_DELAY_MS = 30 * 1000; // 30s after startup
export async function sweepPasteImagesOnce(
ctx: Pick<SessionPort, 'sessions'>,
now: number = Date.now()
): Promise<{ scanned: number; deleted: number }> {
const cutoff = now - MAX_AGE_MS;
let scanned = 0;
let deleted = 0;
for (const session of ctx.sessions.values()) {
const dir = join(session.workingDir, '.claude-images');
let entries: string[];
try {
entries = await fs.readdir(dir);
} catch {
continue; // dir absent — nothing to do
}
for (const name of entries) {
if (!name.startsWith('paste-')) continue;
const p = join(dir, name);
scanned += 1;
try {
const st = await fs.lstat(p);
if (!st.isFile()) continue;
if (st.mtimeMs < cutoff) {
await fs.unlink(p);
deleted += 1;
}
} catch {
// best-effort: skip permission/race errors silently
}
}
}
return { scanned, deleted };
}
export function startPasteImageGc(ctx: Pick<SessionPort, 'sessions'>): () => void {
const initial = setTimeout(() => {
void sweepPasteImagesOnce(ctx);
}, INITIAL_DELAY_MS);
const interval = setInterval(() => {
void sweepPasteImagesOnce(ctx);
}, SWEEP_INTERVAL_MS);
if (typeof initial.unref === 'function') initial.unref();
if (typeof interval.unref === 'function') interval.unref();
return (): void => {
clearTimeout(initial);
clearInterval(interval);
};
}
+21 -12
View File
@@ -625,6 +625,7 @@ class CodemanApp {
console.error('[CRASH-DIAG] WebGL context LOST — falling back to canvas renderer');
_crashDiag.log('WEBGL_LOST');
this._disableWebGLSticky('context-lost');
this._disposeWebGLObserver();
this._webglAddon?.dispose();
this._webglAddon = null;
});
@@ -636,9 +637,10 @@ class CodemanApp {
/**
* Watch for sustained main-thread stalls that indicate WebGL/GPU trouble.
* After 3 long tasks (>=200ms each) within 30s, dispose the WebGL addon and
* persist a sticky disable so subsequent reloads also use the DOM renderer.
* 5s grace period skips initial-load stalls. Force-re-enable: ?webgl=force.
* After WEBGL_FALLBACK.LONGTASK_COUNT long tasks (>=LONGTASK_MS each) within
* WINDOW_MS, dispose the WebGL addon and persist a sticky disable so
* subsequent reloads also use the DOM renderer. GRACE_MS skips initial-load
* stalls. Force-re-enable: ?webgl=force.
*/
_installWebGLLongTaskGuard() {
if (typeof PerformanceObserver === 'undefined' || this._webglLongTaskObserver) return;
@@ -648,19 +650,14 @@ class CodemanApp {
this._webglLongTaskObserver = new PerformanceObserver((list) => {
if (!this._webglAddon) return;
const now = performance.now();
if (now - installedAt < 5000) return;
for (const entry of list.getEntries()) {
if (entry.duration >= 200) recent.push(entry.startTime);
}
while (recent.length && now - recent[0] > 30000) recent.shift();
if (recent.length >= 3) {
console.warn(`[CRASH-DIAG] WebGL long-task threshold (${recent.length} stalls/30s) — falling back to canvas renderer`);
if (now - installedAt < WEBGL_FALLBACK.GRACE_MS) return;
if (evaluateWebGLLongTaskTrip(recent, list.getEntries(), now)) {
console.warn(`[CRASH-DIAG] WebGL long-task threshold (${recent.length} stalls/${WEBGL_FALLBACK.WINDOW_MS}ms) — falling back to canvas renderer`);
_crashDiag.log(`WEBGL_FALLBACK: ${recent.length}`);
this._disableWebGLSticky('long-tasks');
this._disposeWebGLObserver();
this._webglAddon?.dispose();
this._webglAddon = null;
try { this._webglLongTaskObserver.disconnect(); } catch {}
this._webglLongTaskObserver = null;
try { this.terminal.refresh(0, this.terminal.rows - 1); } catch {}
}
});
@@ -668,6 +665,18 @@ class CodemanApp {
} catch { /* longtask not supported */ }
}
/**
* Disconnect the WebGL longtask observer. Idempotent. Called from the trip
* path, the onContextLoss handler, and any future terminal-teardown path —
* the observer outlives its addon otherwise, holding a closure reference
* over `this` for every long task the page emits.
*/
_disposeWebGLObserver() {
if (!this._webglLongTaskObserver) return;
try { this._webglLongTaskObserver.disconnect(); } catch {}
this._webglLongTaskObserver = null;
}
_disableWebGLSticky(reason) {
try {
localStorage.setItem('codeman-webgl-disabled', JSON.stringify({ reason, at: Date.now() }));
+46
View File
@@ -71,6 +71,52 @@ const WINDOW_MIN_WIDTH_PX = 200;
const WINDOW_MIN_HEIGHT_PX = 200;
const WINDOW_DEFAULT_WIDTH_PX = 300;
// WebGL renderer auto-fallback thresholds.
// _installWebGLLongTaskGuard() observes longtask entries and disables WebGL
// after LONGTASK_COUNT stalls of >= LONGTASK_MS within WINDOW_MS. GRACE_MS
// suppresses the noisy initial-load stalls. STICKY_EXPIRY_MS is how long
// localStorage's webgl-disabled marker survives before we retry WebGL on a
// fresh load (driver/Chrome may have been updated).
const WEBGL_FALLBACK = {
LONGTASK_MS: 200,
LONGTASK_COUNT: 3,
WINDOW_MS: 30000,
GRACE_MS: 5000,
STICKY_EXPIRY_MS: 7 * 24 * 60 * 60 * 1000,
};
/**
* Pure rolling-window trip evaluator for the WebGL longtask guard.
* Mutates `recent` in place (prunes entries older than `now - WINDOW_MS`)
* and appends each new duration's startTime that meets the threshold.
* Returns true when the count inside the window reaches `LONGTASK_COUNT`.
*
* Exposed on `window` for unit testing — the production guard in app.js
* inlines this same logic in its PerformanceObserver callback. Splitting it
* out keeps the threshold math testable without a real PerformanceObserver.
*
* @param {number[]} recent - mutable array of startTimes inside the window
* @param {{startTime: number, duration: number}[]} entries - new longtask entries
* @param {number} now - performance.now() at evaluation time
* @param {typeof WEBGL_FALLBACK} [config=WEBGL_FALLBACK] - thresholds
* @returns {boolean} true if the rolling window has reached the trip count
*/
function evaluateWebGLLongTaskTrip(recent, entries, now, config = WEBGL_FALLBACK) {
for (const entry of entries) {
if (entry.duration >= config.LONGTASK_MS) recent.push(entry.startTime);
}
while (recent.length && now - recent[0] > config.WINDOW_MS) recent.shift();
return recent.length >= config.LONGTASK_COUNT;
}
// Expose for tests. `const` declarations at the top of a non-module script
// are global lexical bindings but not `window` properties, so explicit
// assignment is the test-visible API surface.
if (typeof window !== 'undefined') {
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
}
// Scheduler API — prioritize terminal writes over background UI updates.
// scheduler.postTask('background') defers non-critical work (connection lines, panel renders)
// so the main thread stays free for terminal rendering at 60fps.
+7 -2
View File
@@ -87,10 +87,15 @@ Object.assign(CodemanApp.prototype, {
e.preventDefault();
self._uploadAndInsertImages(imageFiles);
} else {
// No image -- extract text and send to terminal
// No image -- route text through xterm's paste() so bracketed-paste
// markers (CSI 200~ ... CSI 201~) survive when the inner application
// has enabled bracketed-paste mode (Claude Code does). Sending text
// via raw sendInput() strips those markers and makes pasted input
// indistinguishable from typed input, weakening the CLI's
// prompt-injection defenses.
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
e.preventDefault();
if (text) self.sendInput(text);
if (text && self.terminal) self.terminal.paste(text);
}
});
+3 -2
View File
@@ -197,8 +197,9 @@ Object.assign(CodemanApp.prototype, {
const raw = localStorage.getItem('codeman-webgl-disabled');
if (!raw) return false;
const { at } = JSON.parse(raw);
// Auto-expire after 7 days so we retry (driver may have been fixed)
if (Date.now() - at > 7 * 24 * 60 * 60 * 1000) {
// Auto-expire after WEBGL_FALLBACK.STICKY_EXPIRY_MS so we retry
// (driver/Chrome may have been updated).
if (Date.now() - at > WEBGL_FALLBACK.STICKY_EXPIRY_MS) {
localStorage.removeItem('codeman-webgl-disabled');
return false;
}
+185 -59
View File
@@ -10,6 +10,7 @@ import { homedir } from 'node:os';
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
import { execFile } from 'node:child_process';
import fs from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import {
ApiErrorCode,
createErrorResponse,
@@ -122,6 +123,76 @@ export function stripInkRedrawBloat(buffer: string): string {
return parts.join('');
}
/**
* Validate image bytes against a declared extension. Sniffs the first ~12 bytes
* for a known magic-number signature. Defends against polyglots (e.g. HTML or
* SVG disguised under a `Content-Type: image/png` header) and against simple
* extension-only spoofing — both the multipart filename and the Content-Type
* are attacker-controlled, the raw bytes are not.
*
* Signatures: https://en.wikipedia.org/wiki/List_of_file_signatures
*/
export function imageMagicMatchesExt(data: Buffer, ext: string): boolean {
if (data.length < 12) return false;
const u32be = (off: number): number => data.readUInt32BE(off);
switch (ext) {
case '.png':
return u32be(0) === 0x89504e47 && u32be(4) === 0x0d0a1a0a;
case '.jpg':
case '.jpeg':
return data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff;
case '.gif':
return (
data[0] === 0x47 &&
data[1] === 0x49 &&
data[2] === 0x46 &&
data[3] === 0x38 &&
(data[4] === 0x37 || data[4] === 0x39) &&
data[5] === 0x61
);
case '.webp':
// RIFF....WEBP
return u32be(0) === 0x52494646 && u32be(8) === 0x57454250;
case '.bmp':
return data[0] === 0x42 && data[1] === 0x4d;
default:
return false;
}
}
// Per-(IP, sessionId) token bucket for paste-image. 30 requests/minute.
// Bucket map entries are pruned when they drift > 1h stale to bound memory
// against a flood of unique IP keys.
const PASTE_RATE_TOKENS = 30;
const PASTE_RATE_REFILL_PER_MS = PASTE_RATE_TOKENS / 60_000;
const PASTE_BUCKET_TTL_MS = 60 * 60 * 1000;
const PASTE_BUCKET_GC_THRESHOLD = 1000;
const pasteRateBuckets = new Map<string, { tokens: number; lastRefill: number }>();
export function consumePasteToken(key: string, now: number = Date.now()): boolean {
if (pasteRateBuckets.size > PASTE_BUCKET_GC_THRESHOLD) {
for (const [k, b] of pasteRateBuckets) {
if (now - b.lastRefill > PASTE_BUCKET_TTL_MS) pasteRateBuckets.delete(k);
}
}
let b = pasteRateBuckets.get(key);
if (!b) {
b = { tokens: PASTE_RATE_TOKENS, lastRefill: now };
pasteRateBuckets.set(key, b);
}
const delta = (now - b.lastRefill) * PASTE_RATE_REFILL_PER_MS;
b.tokens = Math.min(PASTE_RATE_TOKENS, b.tokens + delta);
b.lastRefill = now;
if (b.tokens < 1) return false;
b.tokens -= 1;
return true;
}
// Test hook: reset between runs.
export function _resetPasteRateBuckets(): void {
pasteRateBuckets.clear();
}
export function registerSessionRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
@@ -1442,76 +1513,98 @@ export function registerSessionRoutes(
// Paste Image (clipboard / drag-drop upload)
// ═══════════════════════════════════════════════════════════════
const MAX_PASTE_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB
const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']);
// The 10MB size cap is enforced by @fastify/multipart (registered in server.ts).
app.post('/api/sessions/:id/paste-image', async (req, reply) => {
// CSRF defense: state-changing routes must come from same origin.
// Cookies are SameSite=lax, multipart/form-data is a "simple" CORS request
// (no preflight), so a cross-origin <form enctype="multipart/form-data">
// submit attaches the session cookie unimpeded. Reject unless Origin/Referer
// matches req.host. Non-browser clients (no Origin AND no Referer) must
// supply X-Codeman-CSRF — a header browsers cannot add cross-origin without
// a preflight, which our CORS config does not allow from other origins.
const reqHost = req.headers.host;
const origin = req.headers.origin;
const referer = req.headers.referer;
let csrfOk = false;
if (origin) {
try {
csrfOk = new URL(origin).host === reqHost;
} catch {
/* invalid Origin → not ok */
}
} else if (referer) {
try {
csrfOk = new URL(referer).host === reqHost;
} catch {
/* invalid Referer → not ok */
}
} else {
csrfOk = !!req.headers['x-codeman-csrf'];
}
if (!csrfOk) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'CSRF check failed');
}
const { id } = req.params as { id: string };
// Rate limit per (IP, sessionId): 30/min. Defends against disk-fill DoS
// — even an authenticated attacker can otherwise loop 10MB POSTs.
if (!consumePasteToken(`${req.ip}:${id}`)) {
reply.code(429);
reply.header('Retry-After', '60');
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Rate limit exceeded (30 uploads/min per session)');
}
const session = findSessionOrFail(ctx, id);
const contentType = req.headers['content-type'] ?? '';
if (!contentType.includes('multipart/form-data')) {
if (!req.isMultipart()) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
}
// Parse multipart boundary
const boundaryMatch = contentType.match(/boundary=(.+?)(?:;|$)/);
if (!boundaryMatch) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing boundary');
// Read the single file part. @fastify/multipart enforces the 10MB size cap
// and the 1-file/4-field count limits (server.ts), replacing a hand-rolled
// boundary scanner with several bugs: literal boundary matches anywhere in
// body, LF-only clients silently corrupted the last byte (hard-coded \r\n
// offsets), no part-count cap.
let part: import('@fastify/multipart').MultipartFile | undefined;
try {
part = await req.file();
} catch (err: unknown) {
reply.code(413);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err) || 'Invalid multipart payload');
}
// Collect raw body with size limit
const chunks: Buffer[] = [];
let totalSize = 0;
for await (const chunk of req.raw) {
totalSize += chunk.length;
if (totalSize > MAX_PASTE_IMAGE_SIZE) {
reply.code(413);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'File too large (max 10MB)');
}
chunks.push(chunk as Buffer);
}
const body = Buffer.concat(chunks);
// Extract image from multipart body
const boundary = '--' + boundaryMatch[1];
const boundaryBuf = Buffer.from(boundary);
const parts: { headers: string; data: Buffer }[] = [];
let pos = 0;
while (pos < body.length) {
const start = body.indexOf(boundaryBuf, pos);
if (start === -1) break;
const afterBoundary = start + boundaryBuf.length;
if (body[afterBoundary] === 0x2d && body[afterBoundary + 1] === 0x2d) break;
const headerStart = afterBoundary + 2;
const headerEnd = body.indexOf(Buffer.from('\r\n\r\n'), headerStart);
if (headerEnd === -1) break;
const headers = body.subarray(headerStart, headerEnd).toString();
const dataStart = headerEnd + 4;
const nextBoundary = body.indexOf(boundaryBuf, dataStart);
const dataEnd = nextBoundary === -1 ? body.length : nextBoundary - 2;
parts.push({ headers, data: body.subarray(dataStart, dataEnd) });
pos = nextBoundary === -1 ? body.length : nextBoundary;
}
const imagePart = parts.find((p) => p.headers.includes('name="image"'));
if (!imagePart || imagePart.data.length === 0) {
if (!part) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No image uploaded');
}
if (part.fieldname !== 'image') {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unexpected field "${part.fieldname}", expected "image"`);
}
let imageBytes: Buffer;
try {
imageBytes = await part.toBuffer();
} catch (err: unknown) {
reply.code(413);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err) || 'File too large (max 10MB)');
}
if (imageBytes.length === 0) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Empty file');
}
// Determine extension from filename or Content-Type
// Determine extension from filename or Content-Type.
let ext = '.png';
const filenameMatch = imagePart.headers.match(/filename="(.+?)"/);
if (filenameMatch) {
const origExt = extname(filenameMatch[1]).toLowerCase();
if (part.filename) {
const origExt = extname(part.filename).toLowerCase();
if (ALLOWED_IMAGE_EXTS.has(origExt)) ext = origExt;
}
const ctMatch = imagePart.headers.match(/Content-Type:\s*image\/(png|jpeg|jpg|webp|gif|bmp)/i);
if (ctMatch) {
const mimeMatch = (part.mimetype || '').toLowerCase().match(/^image\/(png|jpeg|jpg|webp|gif|bmp)$/);
if (mimeMatch) {
const map: Record<string, string> = {
png: '.png',
jpeg: '.jpg',
@@ -1520,7 +1613,7 @@ export function registerSessionRoutes(
gif: '.gif',
bmp: '.bmp',
};
ext = map[ctMatch[1].toLowerCase()] ?? ext;
ext = map[mimeMatch[1]] ?? ext;
}
if (!ALLOWED_IMAGE_EXTS.has(ext)) {
@@ -1531,14 +1624,47 @@ export function registerSessionRoutes(
);
}
// Save to {workingDir}/.claude-images/
const imageDir = join(session.workingDir, '.claude-images');
if (!existsSync(imageDir)) {
mkdirSync(imageDir, { recursive: true });
// Sniff actual bytes — filename and Content-Type are both attacker-supplied.
// Polyglot HTML/PNG would otherwise pass and serve back with image/png MIME.
if (!imageMagicMatchesExt(imageBytes, ext)) {
reply.code(415);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
}
const filename = `paste-${Date.now()}${ext}`;
// Save to {workingDir}/.claude-images/
// Refuse symlinks at imageDir — an agent or postinstall script could plant
// `.claude-images -> ~/.ssh/` and redirect future writes outside workingDir.
// We lstat (not stat) so we see the symlink itself. Use mkdir without
// `recursive` so the leaf creation does not follow a symlink either, and
// O_EXCL|O_NOFOLLOW on the file open so the write itself is symlink-safe.
const imageDir = join(session.workingDir, '.claude-images');
try {
const dirStat = await fs.lstat(imageDir);
if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, '.claude-images is not a regular directory');
}
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
// Non-recursive mkdir: errors on EEXIST and does not follow symlinks for
// the leaf. session.workingDir is guaranteed to exist (live session).
await fs.mkdir(imageDir);
}
// Date.now() collides on same-ms uploads from two tabs (last-write wins
// silently). Append 8 hex chars so concurrent pastes get distinct names.
const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
const filepath = join(imageDir, filename);
await fs.writeFile(filepath, imagePart.data);
// O_EXCL: refuse to overwrite (collision is impossible with random suffix,
// but defends against TOCTOU). O_NOFOLLOW: refuse if filepath is a symlink.
const fh = await fs.open(
filepath,
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW
);
try {
await fh.writeFile(imageBytes);
} finally {
await fh.close();
}
return { success: true, path: filepath, filename };
});
+30 -5
View File
@@ -32,6 +32,8 @@ import fastifyCompress from '@fastify/compress';
import fastifyCookie from '@fastify/cookie';
import fastifyStatic from '@fastify/static';
import fastifyWebsocket from '@fastify/websocket';
import fastifyMultipart from '@fastify/multipart';
import { startPasteImageGc } from './paste-image-gc.js';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync } from 'node:fs';
@@ -229,6 +231,7 @@ export class WebServer extends EventEmitter {
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
private teamWatcher: TeamWatcher = new TeamWatcher();
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
private _pasteImageGcStop: (() => void) | null = null;
private teamWatcherHandlers: {
teamCreated: (config: unknown) => void;
teamUpdated: (config: unknown) => void;
@@ -512,11 +515,10 @@ export class WebServer extends EventEmitter {
}
private async setupRoutes(): Promise<void> {
// Allow multipart/form-data for screenshot uploads — skip Fastify's body parser
// so the route handler can read the raw stream directly.
this.app.addContentTypeParser('multipart/form-data', (_req, _payload, done) => {
done(null);
});
// multipart/form-data: parser is provided by @fastify/multipart (registered
// below). Its parser is a no-op marker that leaves the body on req.raw, so
// legacy routes that read the raw stream directly (e.g. /api/screenshots)
// continue to work alongside routes that use req.file() (e.g. paste-image).
// Enable gzip/brotli compression for all responses.
// Massive win: 793KB uncompressed → ~120KB compressed for static assets.
@@ -539,6 +541,18 @@ export class WebServer extends EventEmitter {
// WebSocket support (terminal I/O — low-latency bidirectional channel)
await this.app.register(fastifyWebsocket);
// Multipart parsing (used by paste-image). Replaces a hand-rolled
// boundary scanner that had several edge-case bugs: literal boundary
// anywhere in body was a match, LF-only clients silently corrupted the
// last byte (hard-coded \r\n offsets), and there was no part-count cap.
await this.app.register(fastifyMultipart, {
limits: {
fileSize: 10 * 1024 * 1024, // 10MB per file
files: 1, // paste-image only ever sends one file
fields: 4, // small headroom for accompanying form fields
},
});
// Security headers + CORS
registerSecurityHeaders(this.app, this.https);
this.app.get('/', async (_req, reply) => {
@@ -1541,6 +1555,12 @@ export class WebServer extends EventEmitter {
// Clean up stale sessions from state file that don't have active mux sessions
this.cleanupStaleSessions();
// Bound disk use under heavy paste-image traffic: delete `paste-*` files
// older than 7 days from each live session's .claude-images/ hourly.
if (!this.testMode) {
this._pasteImageGcStop = startPasteImageGc({ sessions: this.sessions });
}
await this.app.listen({ port: this.port, host: '0.0.0.0' });
const protocol = this.https ? 'https' : 'http';
console.log(`Codeman web interface running at ${protocol}://localhost:${this.port}`);
@@ -1894,6 +1914,11 @@ export class WebServer extends EventEmitter {
// Set stopping flag to prevent new timer creation during shutdown
this.sse.setStopping();
if (this._pasteImageGcStop) {
this._pasteImageGcStop();
this._pasteImageGcStop = null;
}
// Dispose all managed timers (intervals + resettable timeouts)
this.cleanup.dispose();
+218
View File
@@ -0,0 +1,218 @@
/**
* WebGL longtask auto-fallback tests.
*
* Covers the three follow-ups from #89:
* 1. Pure trip-detection helper — rolling-window arithmetic for the
* "N longtasks of >=Xms within Yms" trip condition. Unit-tested
* independently of PerformanceObserver, which can't be driven
* deterministically from JS (entries arrive from the platform).
* 2. Constants are hoisted from inline literals to `WEBGL_FALLBACK`
* in constants.js — assert they exist with the documented values.
* 3. Observer disconnect — _disposeWebGLObserver() is idempotent and
* can be called from the onContextLoss path without the addon
* having been initialised. Mirrors the leak case in the issue:
* "observer outlives its addon" when teardown precedes a trip.
*
* Strategy: load the static app shell in a headless browser and drive
* the helper through page.evaluate(). No real PTY/tmux/WebGL needed —
* the trip math is pure and the dispose path is a couple of property
* mutations, both of which run on any page where app.js loaded.
*
* Port: 3166 (per MEMORY.md, ports 3150+ for tests)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page } from 'playwright';
import { WebServer } from '../src/web/server.js';
const PORT = 3166;
const BASE_URL = `http://localhost:${PORT}`;
describe('WebGL longtask auto-fallback', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
beforeAll(async () => {
server = new WebServer(PORT, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage();
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
// Wait for constants.js + app.js to have loaded — both expose globals.
await page.waitForFunction(
() =>
typeof (window as { WEBGL_FALLBACK?: unknown }).WEBGL_FALLBACK !== 'undefined' &&
typeof (window as { evaluateWebGLLongTaskTrip?: unknown }).evaluateWebGLLongTaskTrip === 'function' &&
typeof (window as { app?: unknown }).app !== 'undefined'
);
}, 60000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
}, 60000);
describe('constants are hoisted', () => {
it('WEBGL_FALLBACK exposes documented thresholds', async () => {
const cfg = await page.evaluate(
() => (window as unknown as { WEBGL_FALLBACK: Record<string, number> }).WEBGL_FALLBACK
);
expect(cfg).toEqual({
LONGTASK_MS: 200,
LONGTASK_COUNT: 3,
WINDOW_MS: 30000,
GRACE_MS: 5000,
STICKY_EXPIRY_MS: 7 * 24 * 60 * 60 * 1000,
});
});
});
describe('evaluateWebGLLongTaskTrip — rolling window arithmetic', () => {
type EvalFn = (
recent: number[],
entries: { startTime: number; duration: number }[],
now: number
) => { tripped: boolean; recent: number[] };
/** Run the pure helper in the page and return the post-call state. */
const run: EvalFn = async (recent, entries, now) =>
page.evaluate(
({ r, e, n }) => {
const fn = (
window as unknown as {
evaluateWebGLLongTaskTrip: (
rec: number[],
ents: { startTime: number; duration: number }[],
now: number
) => boolean;
}
).evaluateWebGLLongTaskTrip;
const recent = [...r];
const tripped = fn(recent, e, n);
return { tripped, recent };
},
{ r: recent, e: entries, n: now }
) as unknown as { tripped: boolean; recent: number[] };
it('trips when 3 longtasks fall inside the 30s window', async () => {
const entries = [
{ startTime: 1000, duration: 250 },
{ startTime: 5000, duration: 300 },
{ startTime: 10000, duration: 220 },
];
const result = await run([], entries, 12000);
expect(result.tripped).toBe(true);
expect(result.recent).toEqual([1000, 5000, 10000]);
});
it('does not trip when 3 longtasks are spread across 60s', async () => {
// 3 longtasks 25s apart — only the most recent two stay inside 30s.
const entries = [
{ startTime: 1000, duration: 250 },
{ startTime: 26000, duration: 250 },
{ startTime: 51000, duration: 250 },
];
const result = await run([], entries, 51100);
expect(result.tripped).toBe(false);
// First entry pruned (1000 is >30s before now=51100); 26000 and 51000 stay.
expect(result.recent).toEqual([26000, 51000]);
});
it('ignores entries shorter than 200ms', async () => {
const entries = [
{ startTime: 1000, duration: 199 },
{ startTime: 2000, duration: 100 },
{ startTime: 3000, duration: 50 },
];
const result = await run([], entries, 3500);
expect(result.tripped).toBe(false);
expect(result.recent).toEqual([]);
});
it('prunes stale entries even when no new ones arrive', async () => {
// Existing window has 2 stale + 1 fresh; an empty batch should still
// age out the stale ones so the next real batch evaluates correctly.
const recent = [1000, 5000, 40000];
const result = await run(recent, [], 41000);
expect(result.tripped).toBe(false);
expect(result.recent).toEqual([40000]);
});
it('counts entries cumulatively across batches', async () => {
// Two batches of 2 entries each, all inside the window — second
// batch should push the cumulative count to 4 and trip.
const recent: number[] = [];
const first = await run(
recent,
[
{ startTime: 1000, duration: 250 },
{ startTime: 2000, duration: 250 },
],
3000
);
expect(first.tripped).toBe(false);
expect(first.recent).toEqual([1000, 2000]);
const second = await run(
first.recent,
[
{ startTime: 4000, duration: 250 },
{ startTime: 5000, duration: 250 },
],
6000
);
expect(second.tripped).toBe(true);
expect(second.recent).toEqual([1000, 2000, 4000, 5000]);
});
});
describe('_disposeWebGLObserver', () => {
it('is idempotent — safe to call when no observer was installed', async () => {
const ok = await page.evaluate(() => {
const app = (
window as unknown as { app: { _disposeWebGLObserver: () => void; _webglLongTaskObserver: unknown } }
).app;
app._webglLongTaskObserver = null;
app._disposeWebGLObserver();
app._disposeWebGLObserver();
return app._webglLongTaskObserver === null;
});
expect(ok).toBe(true);
});
it('disconnects a stub observer and nulls the reference', async () => {
const result = await page.evaluate(() => {
const app = (
window as unknown as { app: { _disposeWebGLObserver: () => void; _webglLongTaskObserver: unknown } }
).app;
let disconnectCalls = 0;
app._webglLongTaskObserver = {
disconnect() {
disconnectCalls++;
},
} as unknown;
app._disposeWebGLObserver();
return { disconnectCalls, ref: app._webglLongTaskObserver };
});
expect(result.disconnectCalls).toBe(1);
expect(result.ref).toBeNull();
});
it('swallows a throwing disconnect — guards against driver quirks', async () => {
const result = await page.evaluate(() => {
const app = (
window as unknown as { app: { _disposeWebGLObserver: () => void; _webglLongTaskObserver: unknown } }
).app;
app._webglLongTaskObserver = {
disconnect() {
throw new Error('synthetic');
},
} as unknown;
app._disposeWebGLObserver();
return app._webglLongTaskObserver;
});
expect(result).toBeNull();
});
});
});