mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08de6667ab | ||
|
|
d27f8e77f7 | ||
|
|
e248cd8bcf | ||
|
|
73d81afd4d | ||
|
|
7884a37c55 | ||
|
|
ad89a97106 | ||
|
|
0600b7843e | ||
|
|
6d896c781e | ||
|
|
930492058b | ||
|
|
101cee0cec |
@@ -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
|
||||
|
||||
@@ -1,5 +1,48 @@
|
||||
# aicodeman
|
||||
|
||||
## 0.6.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Resume Conversation: fixes and folder drill-down.
|
||||
- **fix(history)**: `decodeProjectKey()` now uses longest-join-first backtracking with on-disk validation, so sibling directories sharing a prefix (e.g. `diary/` vs `diary-app/`) resolve to the correct path. Previously the greedy shortest-match decoder picked the shorter name and bailed, surfacing `$HOME` in the Resume Conversation list and resuming into the wrong folder. Greedy decode is kept as a fallback so history for deleted projects still resolves. (#92)
|
||||
- **fix(tabs)**: Drop the client-side resurrection of ended-session tabs. The old code cached open tabs in `localStorage` and rebuilt them as grayed-out stubs whenever the server no longer knew them, which left phantom tabs after closing a session on another device. The server is now the single source of truth; legacy `localStorage` keys are purged on init. Net -44 / +6 lines. (#93)
|
||||
- **feat(history)**: New "View all in this folder" drill-down on Resume Conversation. `GET /api/history/sessions` accepts `projectKey` (validated against `^[A-Za-z0-9_-]+$` before any filesystem access), `offset`, and `limit`; single-folder mode bypasses the 50-cap and returns `{ sessions, total }`. Frontend adds a modal listing 20 sessions per page with a "Show more" pagination button. Modal items omit their own "View all" button to prevent recursive entry points. (#94)
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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.11 (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`.
|
||||
|
||||
Generated
+2033
-2
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.6.9",
|
||||
"version": "0.6.11",
|
||||
"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",
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
+27
-54
@@ -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() }));
|
||||
@@ -1785,8 +1794,10 @@ class CodemanApp {
|
||||
}
|
||||
});
|
||||
|
||||
// Restore tabs that were open before refresh but are no longer on the server
|
||||
this._restoreEndedTabs();
|
||||
// Server is source of truth for open sessions — don't resurrect stale tabs
|
||||
// from localStorage (would show phantom "ended" tabs when a session was closed
|
||||
// on another device).
|
||||
try { localStorage.removeItem('codeman-tab-meta'); } catch {}
|
||||
|
||||
// Sync sessionOrder with current sessions (preserve order, add new, remove stale)
|
||||
this.syncSessionOrder();
|
||||
@@ -2108,8 +2119,7 @@ class CodemanApp {
|
||||
const tallTabsEnabled = this._tallTabsEnabled ?? false;
|
||||
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
|
||||
|
||||
const endedAttr = session._ended ? ' data-ended="1"' : '';
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}" data-id="${id}" data-color="${color}"${endedAttr} onclick="app.selectSession('${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
|
||||
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}" data-id="${id}" data-color="${color}" onclick="app.selectSession('${escapeHtml(id)}')" oncontextmenu="event.preventDefault(); app.startInlineRename('${escapeHtml(id)}')" tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-label="${escapeHtml(name)} session" ${session.workingDir ? `title="${escapeHtml(session.workingDir)}"` : ''}>
|
||||
${_tabIdx < 9 ? '<span class="tab-number">' + (_tabIdx + 1) + '</span>' : ''}
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
<span class="tab-info">
|
||||
@@ -2129,9 +2139,6 @@ class CodemanApp {
|
||||
|
||||
container.innerHTML = parts.join('');
|
||||
|
||||
// Persist tab metadata for refresh recovery
|
||||
this._saveTabMetadata();
|
||||
|
||||
// Set up drag-and-drop handlers for tab reordering
|
||||
this.setupTabDragHandlers();
|
||||
|
||||
@@ -2231,33 +2238,6 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Save tab metadata to localStorage so ended sessions can be restored after refresh
|
||||
_saveTabMetadata() {
|
||||
try {
|
||||
const meta = {};
|
||||
for (const [id, s] of this.sessions) {
|
||||
if (s._ended) continue; // Don't persist ended stubs back
|
||||
meta[id] = { id, name: s.name || '', workingDir: s.workingDir || '', mode: s.mode || 'claude', color: s.color || 'default' };
|
||||
}
|
||||
localStorage.setItem('codeman-tab-meta', JSON.stringify(meta));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Restore tabs that were open before refresh but are no longer on the server
|
||||
_restoreEndedTabs() {
|
||||
try {
|
||||
const saved = localStorage.getItem('codeman-tab-meta');
|
||||
if (!saved) return;
|
||||
const meta = JSON.parse(saved);
|
||||
for (const [id, info] of Object.entries(meta)) {
|
||||
if (!this.sessions.has(id)) {
|
||||
// Add a stub session so the tab renders
|
||||
this.sessions.set(id, { id, name: info.name, workingDir: info.workingDir, mode: info.mode, color: info.color, status: 'ended', _ended: true });
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Set up drag-and-drop handlers on tab elements
|
||||
setupTabDragHandlers() {
|
||||
const container = this.$('sessionTabs');
|
||||
@@ -2537,16 +2517,9 @@ class CodemanApp {
|
||||
// Check if this is a restored session that needs to be attached
|
||||
const session = this.sessions.get(sessionId);
|
||||
|
||||
// Ended tabs (restored from localStorage, no longer on server) — show message, skip buffer load
|
||||
if (session?._ended) {
|
||||
this.terminal.clear();
|
||||
this.terminal.write('\r\n \x1b[2mSession ended. Close tab or click to reopen.\x1b[0m\r\n');
|
||||
return;
|
||||
}
|
||||
|
||||
// Track working directory for path normalization in Project Insights
|
||||
this.currentSessionWorkingDir = session?.workingDir || null;
|
||||
if (session && session.pid === null && !session._ended) {
|
||||
if (session && session.pid === null) {
|
||||
// Session has no PTY attached — either restored after server restart
|
||||
// or detached for some other reason. Re-attach regardless of status.
|
||||
try {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -293,7 +293,6 @@ body {
|
||||
}
|
||||
.session-tab .tab-status.error { background: var(--red); }
|
||||
.session-tab .tab-status.ended { background: var(--text-muted); opacity: 0.5; }
|
||||
.session-tab[data-ended] { opacity: 0.55; }
|
||||
|
||||
/* Session color coding - left border indicator */
|
||||
.session-tab[data-color="red"] { border-left: 3px solid var(--session-red); }
|
||||
@@ -2370,6 +2369,56 @@ body {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.history-detail-actions {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.history-view-all-btn {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.75rem;
|
||||
background: rgba(99, 179, 237, 0.08);
|
||||
border: 1px solid rgba(99, 179, 237, 0.25);
|
||||
border-radius: 6px;
|
||||
color: rgba(99, 179, 237, 0.95);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-smooth), border-color var(--transition-smooth);
|
||||
}
|
||||
|
||||
.history-view-all-btn:hover {
|
||||
background: rgba(99, 179, 237, 0.15);
|
||||
border-color: rgba(99, 179, 237, 0.5);
|
||||
}
|
||||
|
||||
/* Folder history modal */
|
||||
.folder-history-modal .modal-body {
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.folder-history-subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-family: 'SF Mono', Menlo, Consolas, monospace;
|
||||
word-break: break-all;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.folder-history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.folder-history-empty {
|
||||
padding: 2rem 0.5rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.welcome-hint {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -443,7 +444,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (
|
||||
activeResizeSession &&
|
||||
activeResizeSession.mode !== 'shell' &&
|
||||
!activeResizeSession._ended &&
|
||||
this.terminal &&
|
||||
this.isTerminalAtBottom()
|
||||
) {
|
||||
@@ -917,8 +917,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
.replace(/^\/Users\/[^/]+\//, '~/');
|
||||
},
|
||||
|
||||
/** Build a single history item DOM element */
|
||||
_buildHistoryItem(s, cases) {
|
||||
/**
|
||||
* Build a single history item DOM element.
|
||||
* @param {object} s session record
|
||||
* @param {Array} cases linked cases (for #caseName label)
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.showViewAll=true] show "View all in folder" button in detail panel
|
||||
*/
|
||||
_buildHistoryItem(s, cases, options) {
|
||||
const showViewAll = options?.showViewAll !== false;
|
||||
const size =
|
||||
s.sizeBytes < 1024
|
||||
? `${s.sizeBytes}B`
|
||||
@@ -1000,6 +1007,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
detail.append(promptRow, pathRow, metaRow);
|
||||
|
||||
if (showViewAll && s.projectKey) {
|
||||
const actionRow = document.createElement('div');
|
||||
actionRow.className = 'history-detail-row history-detail-actions';
|
||||
const viewAllBtn = document.createElement('button');
|
||||
viewAllBtn.type = 'button';
|
||||
viewAllBtn.className = 'history-view-all-btn';
|
||||
viewAllBtn.textContent = 'View all in this folder';
|
||||
viewAllBtn.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
this.openFolderHistoryModal(s.projectKey, s.workingDir, cases);
|
||||
});
|
||||
actionRow.appendChild(viewAllBtn);
|
||||
detail.appendChild(actionRow);
|
||||
}
|
||||
|
||||
expandBtn.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
const expanded = item.classList.toggle('expanded');
|
||||
@@ -1063,9 +1085,144 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
/** Page size for the folder history modal */
|
||||
_FOLDER_HISTORY_PAGE_SIZE: 20,
|
||||
|
||||
/**
|
||||
* Open a modal showing all history sessions in a single folder.
|
||||
* Paginated by FOLDER_HISTORY_PAGE_SIZE; "Show more" loads next page.
|
||||
*/
|
||||
openFolderHistoryModal(projectKey, workingDir, cases) {
|
||||
// Close any existing instance first
|
||||
this._closeFolderHistoryModal();
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal active folder-history-modal';
|
||||
modal.id = 'folderHistoryModal';
|
||||
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'modal-backdrop';
|
||||
backdrop.addEventListener('click', () => this._closeFolderHistoryModal());
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'modal-content modal-lg';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'modal-header';
|
||||
const title = document.createElement('h3');
|
||||
title.textContent = 'Folder History';
|
||||
const subtitle = document.createElement('div');
|
||||
subtitle.className = 'folder-history-subtitle';
|
||||
subtitle.textContent = this._shortenHomePath(workingDir);
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'modal-close';
|
||||
closeBtn.setAttribute('aria-label', 'Close');
|
||||
closeBtn.innerHTML = '×';
|
||||
closeBtn.addEventListener('click', () => this._closeFolderHistoryModal());
|
||||
header.append(title, closeBtn);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'modal-body';
|
||||
const list = document.createElement('div');
|
||||
list.className = 'folder-history-list';
|
||||
list.setAttribute('data-loading', 'true');
|
||||
list.textContent = 'Loading...';
|
||||
body.append(subtitle, list);
|
||||
|
||||
content.append(header, body);
|
||||
modal.append(backdrop, content);
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Track state for pagination
|
||||
this._folderHistoryState = {
|
||||
projectKey,
|
||||
workingDir,
|
||||
cases: cases || [],
|
||||
offset: 0,
|
||||
total: null,
|
||||
list,
|
||||
};
|
||||
|
||||
// ESC to close
|
||||
this._folderHistoryEscHandler = (ev) => {
|
||||
if (ev.key === 'Escape') this._closeFolderHistoryModal();
|
||||
};
|
||||
document.addEventListener('keydown', this._folderHistoryEscHandler);
|
||||
|
||||
this._loadFolderHistoryPage();
|
||||
},
|
||||
|
||||
async _loadFolderHistoryPage() {
|
||||
const state = this._folderHistoryState;
|
||||
if (!state) return;
|
||||
const { projectKey, cases, list } = state;
|
||||
const limit = this._FOLDER_HISTORY_PAGE_SIZE;
|
||||
const offset = state.offset;
|
||||
|
||||
// Remove existing "Show more" button while loading
|
||||
const existingMore = list.querySelector('.folder-history-more');
|
||||
if (existingMore) existingMore.remove();
|
||||
|
||||
// First page: clear loading placeholder
|
||||
if (offset === 0) {
|
||||
list.replaceChildren();
|
||||
list.removeAttribute('data-loading');
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `/api/history/sessions?projectKey=${encodeURIComponent(projectKey)}&offset=${offset}&limit=${limit}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
const sessions = data.sessions || [];
|
||||
state.total = typeof data.total === 'number' ? data.total : sessions.length + offset;
|
||||
|
||||
if (offset === 0 && sessions.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'folder-history-empty';
|
||||
empty.textContent = 'No conversations found in this folder.';
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const s of sessions) {
|
||||
list.appendChild(this._buildHistoryItem(s, cases, { showViewAll: false }));
|
||||
}
|
||||
|
||||
state.offset = offset + sessions.length;
|
||||
|
||||
// Add "Show more" if there are more sessions
|
||||
if (state.offset < state.total) {
|
||||
const remaining = state.total - state.offset;
|
||||
const moreBtn = document.createElement('button');
|
||||
moreBtn.className = 'history-show-more folder-history-more';
|
||||
moreBtn.textContent = `Show ${Math.min(limit, remaining)} more (${remaining} remaining)`;
|
||||
moreBtn.addEventListener('click', () => this._loadFolderHistoryPage());
|
||||
list.appendChild(moreBtn);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[loadFolderHistoryPage]', err);
|
||||
const errorEl = document.createElement('div');
|
||||
errorEl.className = 'folder-history-empty';
|
||||
errorEl.textContent = 'Failed to load folder history.';
|
||||
list.appendChild(errorEl);
|
||||
}
|
||||
},
|
||||
|
||||
_closeFolderHistoryModal() {
|
||||
const modal = document.getElementById('folderHistoryModal');
|
||||
if (modal) modal.remove();
|
||||
if (this._folderHistoryEscHandler) {
|
||||
document.removeEventListener('keydown', this._folderHistoryEscHandler);
|
||||
this._folderHistoryEscHandler = null;
|
||||
}
|
||||
this._folderHistoryState = null;
|
||||
},
|
||||
|
||||
async resumeHistorySession(sessionId, workingDir) {
|
||||
// Close the run mode menu if open
|
||||
document.getElementById('runModeMenu')?.classList.remove('active');
|
||||
// Close folder history modal if open
|
||||
this._closeFolderHistoryModal();
|
||||
try {
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Resuming conversation ${sessionId.slice(0, 8)}...\x1b[0m`);
|
||||
|
||||
+318
-152
@@ -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
|
||||
@@ -1253,42 +1324,76 @@ export function registerSessionRoutes(
|
||||
* Claude CLI encodes both '/' and '_' as '-', so each '-' in the key could be
|
||||
* any of: '/' (path separator), '_' (underscore), or '-' (literal dash).
|
||||
*
|
||||
* Strategy: look-ahead matching. At each '-', try consuming multiple segments
|
||||
* joined by '_' or '-' to find an existing child directory, then recurse.
|
||||
* E.g. for segments [AI, project, Mirror] inside /Workspace:
|
||||
* try /Workspace/AI (no) -> /Workspace/AI_project (yes!) -> continue with [Mirror]
|
||||
* Strategy: recursive backtracking with longest-match-first preference.
|
||||
* At each segment boundary, try joining as many segments as possible (with '_'
|
||||
* or '-') into a single existing directory name. If a shorter match leads to a
|
||||
* dead end, backtrack and try the next-shorter candidate.
|
||||
*
|
||||
* Why backtracking: when both `diary/` and `diary-app/` exist as siblings, the
|
||||
* naive shortest-match would pick `diary` and then fail to find `app` inside,
|
||||
* leaving the rest of the key unresolved. Longest-first picks `diary-app`.
|
||||
*/
|
||||
async function decodeProjectKey(projKey: string): Promise<string> {
|
||||
const encoded = projKey.startsWith('-') ? projKey.slice(1) : projKey;
|
||||
const segments = encoded.split('-');
|
||||
|
||||
const isDir = async (p: string): Promise<boolean> =>
|
||||
fs
|
||||
const isDirCache = new Map<string, boolean>();
|
||||
const isDir = async (p: string): Promise<boolean> => {
|
||||
const cached = isDirCache.get(p);
|
||||
if (cached !== undefined) return cached;
|
||||
const result = await fs
|
||||
.stat(p)
|
||||
.then((s) => s.isDirectory())
|
||||
.catch(() => false);
|
||||
isDirCache.set(p, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
// Recursive backtracking: returns the deepest valid path that consumes all
|
||||
// segments. Tries the longest segment-join first at each step so that
|
||||
// dash-containing directory names win over shorter same-prefix siblings.
|
||||
async function tryDecode(idx: number, current: string): Promise<string | null> {
|
||||
if (idx >= segments.length) return current;
|
||||
const maxLook = Math.min(idx + 4, segments.length);
|
||||
// Longest first: end = maxLook-1 down to idx
|
||||
for (let end = maxLook - 1; end >= idx; end--) {
|
||||
const candidates: string[] = [];
|
||||
if (end === idx) {
|
||||
candidates.push(segments[idx]);
|
||||
} else {
|
||||
candidates.push(segments.slice(idx, end + 1).join('-'));
|
||||
candidates.push(segments.slice(idx, end + 1).join('_'));
|
||||
}
|
||||
for (const child of candidates) {
|
||||
const candidate = current + '/' + child;
|
||||
if (await isDir(candidate)) {
|
||||
const result = await tryDecode(end + 1, candidate);
|
||||
if (result) return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const decoded = await tryDecode(0, '');
|
||||
if (decoded) return decoded;
|
||||
|
||||
// Fallback: greedy shortest-match (original behavior) — best effort when
|
||||
// no fully-valid path exists (e.g. directory was deleted after the
|
||||
// conversation was recorded).
|
||||
let current = '';
|
||||
let i = 0;
|
||||
|
||||
while (i < segments.length) {
|
||||
// Try progressively longer child names by joining segments with '_' or '-'
|
||||
let matched = false;
|
||||
// Limit look-ahead to avoid excessive fs checks (max 4 segments per component)
|
||||
const maxLook = Math.min(i + 4, segments.length);
|
||||
for (let end = i; end < maxLook; end++) {
|
||||
// Build candidate child name from segments[i..end]
|
||||
// Try all separator combinations: for 2+ segments, try '_' first then '-'
|
||||
const candidates: string[] = [];
|
||||
if (end === i) {
|
||||
candidates.push(segments[i]);
|
||||
} else {
|
||||
// Build with underscores between joined segments
|
||||
candidates.push(segments.slice(i, end + 1).join('_'));
|
||||
// Build with dashes (literal)
|
||||
candidates.push(segments.slice(i, end + 1).join('-'));
|
||||
}
|
||||
|
||||
for (const child of candidates) {
|
||||
const candidate = current + '/' + child;
|
||||
if (await isDir(candidate)) {
|
||||
@@ -1301,12 +1406,10 @@ export function registerSessionRoutes(
|
||||
if (matched) break;
|
||||
}
|
||||
if (!matched) {
|
||||
// No directory match found — append as-is and move on
|
||||
current = current + '/' + segments[i];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
const finalExists = await fs
|
||||
.access(current)
|
||||
.then(() => true)
|
||||
@@ -1345,96 +1448,104 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/api/history/sessions', async () => {
|
||||
type HistorySession = {
|
||||
sessionId: string;
|
||||
workingDir: string;
|
||||
projectKey: string;
|
||||
sizeBytes: number;
|
||||
lastModified: string;
|
||||
firstPrompt?: string;
|
||||
};
|
||||
|
||||
// Scan a single project directory and return all valid history sessions in it.
|
||||
// Reused by both the global overview and the single-folder drill-down.
|
||||
async function scanProjectDir(projPath: string, projDir: string, headBuf: Buffer): Promise<HistorySession[]> {
|
||||
const out: HistorySession[] = [];
|
||||
const stat = await fs.stat(projPath).catch(() => null);
|
||||
if (!stat?.isDirectory()) return out;
|
||||
|
||||
const workingDir = await decodeProjectKey(projDir);
|
||||
const entries = await fs.readdir(projPath).catch(() => [] as string[]);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith('.jsonl')) continue;
|
||||
const sessionId = entry.replace('.jsonl', '');
|
||||
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(sessionId)) continue;
|
||||
|
||||
const filePath = join(projPath, entry);
|
||||
const fileStat = await fs.stat(filePath).catch(() => null);
|
||||
if (!fileStat) continue;
|
||||
if (fileStat.size < 4000) continue;
|
||||
|
||||
let firstPrompt: string | undefined;
|
||||
const head = await readFileHead(filePath, headBuf);
|
||||
const hasConversation = (text: string) =>
|
||||
text.includes('"type":"user"') || text.includes('"type":"assistant"') || text.includes('"type":"summary"');
|
||||
|
||||
let foundContent = head ? hasConversation(head) : false;
|
||||
let tail: string | null = null;
|
||||
if (!foundContent && fileStat.size > 16384) {
|
||||
const tailBuf = Buffer.alloc(32768);
|
||||
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||
if (tail) foundContent = hasConversation(tail);
|
||||
}
|
||||
if (!foundContent) continue;
|
||||
|
||||
if (head) firstPrompt = extractFirstUserPrompt(head);
|
||||
if (!firstPrompt && fileStat.size > 65536) {
|
||||
if (!tail) {
|
||||
const tailBuf = Buffer.alloc(32768);
|
||||
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||
}
|
||||
if (tail) firstPrompt = extractFirstUserPrompt(tail);
|
||||
}
|
||||
|
||||
out.push({
|
||||
sessionId,
|
||||
workingDir,
|
||||
projectKey: projDir,
|
||||
sizeBytes: fileStat.size,
|
||||
lastModified: fileStat.mtime.toISOString(),
|
||||
firstPrompt,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
app.get('/api/history/sessions', async (req) => {
|
||||
const query = req.query as { projectKey?: string; offset?: string; limit?: string };
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
const results: Array<{
|
||||
sessionId: string;
|
||||
workingDir: string;
|
||||
projectKey: string;
|
||||
sizeBytes: number;
|
||||
lastModified: string;
|
||||
firstPrompt?: string;
|
||||
}> = [];
|
||||
const headBuf = Buffer.alloc(16384);
|
||||
|
||||
// Single-folder drill-down: when projectKey is provided, scan only that
|
||||
// directory, bypass the 50-cap, and honor offset/limit pagination.
|
||||
if (query.projectKey) {
|
||||
// Validate projectKey format to prevent path traversal
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(query.projectKey)) {
|
||||
return { sessions: [], total: 0 };
|
||||
}
|
||||
const offset = Math.max(0, parseInt(query.offset || '0', 10) || 0);
|
||||
const limit = Math.min(100, Math.max(1, parseInt(query.limit || '20', 10) || 20));
|
||||
const projPath = join(projectsDir, query.projectKey);
|
||||
const all = await scanProjectDir(projPath, query.projectKey, headBuf);
|
||||
all.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
||||
return { sessions: all.slice(offset, offset + limit), total: all.length };
|
||||
}
|
||||
|
||||
// Global overview: scan all projects, return up to 50 most-recent sessions.
|
||||
const results: HistorySession[] = [];
|
||||
try {
|
||||
const projectDirs = await fs.readdir(projectsDir);
|
||||
for (const projDir of projectDirs) {
|
||||
const projPath = join(projectsDir, projDir);
|
||||
const stat = await fs.stat(projPath).catch(() => null);
|
||||
if (!stat?.isDirectory()) continue;
|
||||
|
||||
// Decode project key to working dir. Claude CLI encodes '/' as '-',
|
||||
// but path components may also contain '-' (e.g. "AI_project" vs "AI-project").
|
||||
// Use recursive backtracking: try each '-' as either '/' or literal '-',
|
||||
// verify which decoded path actually exists on disk.
|
||||
const workingDir = await decodeProjectKey(projDir);
|
||||
|
||||
const entries = await fs.readdir(projPath);
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith('.jsonl')) continue;
|
||||
const sessionId = entry.replace('.jsonl', '');
|
||||
// Only valid UUIDs
|
||||
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(sessionId)) continue;
|
||||
|
||||
const filePath = join(projPath, entry);
|
||||
const fileStat = await fs.stat(filePath).catch(() => null);
|
||||
if (!fileStat) continue;
|
||||
// Skip files too small to contain real conversation (metadata-only sessions
|
||||
// like file-history-snapshot entries are typically < 4KB)
|
||||
if (fileStat.size < 4000) continue;
|
||||
|
||||
// Quick content check: verify actual conversation data exists.
|
||||
// Sessions with only file-history-snapshot or hook_progress entries have
|
||||
// no "user"/"assistant" messages and will fail claude --resume.
|
||||
// Read first 16KB to check content and extract first user prompt.
|
||||
let firstPrompt: string | undefined;
|
||||
const head = await readFileHead(filePath, headBuf);
|
||||
const hasConversation = (text: string) =>
|
||||
text.includes('"type":"user"') || text.includes('"type":"assistant"') || text.includes('"type":"summary"');
|
||||
|
||||
let foundContent = head ? hasConversation(head) : false;
|
||||
|
||||
// For large files, head may not contain user messages (e.g. /init followed
|
||||
// by large system entries). Check the tail as well.
|
||||
let tail: string | null = null;
|
||||
if (!foundContent && fileStat.size > 16384) {
|
||||
const tailBuf = Buffer.alloc(32768);
|
||||
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||
if (tail) foundContent = hasConversation(tail);
|
||||
}
|
||||
|
||||
if (!foundContent) continue; // No conversation content — skip
|
||||
|
||||
if (head) firstPrompt = extractFirstUserPrompt(head);
|
||||
|
||||
// If head scan found no usable prompt (e.g. session started with /init),
|
||||
// try reading the tail for a recent user message.
|
||||
if (!firstPrompt && fileStat.size > 65536) {
|
||||
if (!tail) {
|
||||
const tailBuf = Buffer.alloc(32768);
|
||||
tail = await readFileTail(filePath, tailBuf, fileStat.size);
|
||||
}
|
||||
if (tail) firstPrompt = extractFirstUserPrompt(tail);
|
||||
}
|
||||
|
||||
results.push({
|
||||
sessionId,
|
||||
workingDir,
|
||||
projectKey: projDir,
|
||||
sizeBytes: fileStat.size,
|
||||
lastModified: fileStat.mtime.toISOString(),
|
||||
firstPrompt,
|
||||
});
|
||||
}
|
||||
const list = await scanProjectDir(projPath, projDir, headBuf);
|
||||
results.push(...list);
|
||||
}
|
||||
} catch {
|
||||
// Projects dir may not exist
|
||||
}
|
||||
|
||||
// Sort by lastModified descending
|
||||
results.sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime());
|
||||
|
||||
return { sessions: results.slice(0, 50) };
|
||||
});
|
||||
|
||||
@@ -1442,76 +1553,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 +1653,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 +1664,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
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user