Merge remote-tracking branch 'origin/master' into cod-28-security-public-assets

This commit is contained in:
arkon
2026-06-08 18:05:10 +02:00
46 changed files with 6661 additions and 322 deletions
+30 -12
View File
@@ -8,7 +8,7 @@
* - CORS (localhost only)
*/
import { FastifyInstance } from 'fastify';
import type { FastifyInstance, FastifyReply } from 'fastify';
import { randomBytes, timingSafeEqual } from 'node:crypto';
import { StaleExpirationMap } from '../../utils/index.js';
import type { AuthSessionRecord } from '../ports/auth-port.js';
@@ -69,6 +69,13 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
const authSessions = state.authSessions;
const authFailures = state.authFailures;
function sendAuthRateLimit(reply: FastifyReply, clientIp: string): void {
const remainingMs = authFailures.getRemainingTtl(clientIp) ?? AUTH_FAILURE_WINDOW_MS;
const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000));
reply.header('Retry-After', String(retryAfterSeconds));
reply.code(429).send('Too Many Requests — try again later');
}
app.addHook('onRequest', (req, reply, done) => {
// Hook events come from local Claude Code hooks (curl from localhost) — no auth headers available.
// Safe: validated by HookEventSchema, only triggers broadcasts.
@@ -90,13 +97,6 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
const clientIp = req.ip;
// Rate limit: reject if too many failed attempts from this IP
const failures = authFailures.get(clientIp) ?? 0;
if (failures >= AUTH_FAILURE_MAX) {
reply.code(429).send('Too Many Requests — try again later');
return;
}
// Check session cookie first (avoids re-sending credentials on every request)
// Use get() instead of has() so refreshOnGet extends the TTL on active sessions
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
@@ -140,6 +140,13 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
return;
}
// Rate limit only requests that failed to authenticate on this attempt.
const failures = authFailures.get(clientIp) ?? 0;
if (failures >= AUTH_FAILURE_MAX) {
sendAuthRateLimit(reply, clientIp);
return;
}
// Auth failed — track failure count
authFailures.set(clientIp, failures + 1);
@@ -154,13 +161,24 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
* Register security headers and CORS middleware on every response.
*/
export function registerSecurityHeaders(app: FastifyInstance, https: boolean): void {
// Gesture-control overlay (opt-in via CODEMAN_GESTURE=1) runs MediaPipe, which
// needs WebAssembly eval (script-src) and blob workers (worker-src). Its wasm
// runtime + model are self-hosted under /gesture/ (same-origin, covered by
// 'self'), so no CDN connect-src entries are needed. OFF by default so the
// production CSP is byte-for-byte unchanged.
const gesture = process.env.CODEMAN_GESTURE === '1';
const scriptSrc =
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net" + (gesture ? " 'wasm-unsafe-eval'" : '');
const connectSrc = "connect-src 'self' wss://api.deepgram.com";
const workerSrc = gesture ? "; worker-src 'self' blob:" : '';
const csp =
`default-src 'self'; ${scriptSrc}; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; ` +
`img-src 'self' data: blob:; ${connectSrc}; font-src 'self' https://cdn.jsdelivr.net; frame-ancestors 'self'${workerSrc}`;
app.addHook('onRequest', (req, reply, done) => {
reply.header('X-Content-Type-Options', 'nosniff');
reply.header('X-Frame-Options', 'SAMEORIGIN');
reply.header(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: blob:; connect-src 'self' wss://api.deepgram.com; font-src 'self' https://cdn.jsdelivr.net; frame-ancestors 'self'"
);
reply.header('Content-Security-Policy', csp);
if (https) {
reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
+21
View File
@@ -0,0 +1,21 @@
import { isIP } from 'node:net';
const EXPLICIT_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
export function isExplicitlyEnabled(value: string | undefined): boolean {
return value !== undefined && EXPLICIT_TRUE_VALUES.has(value.trim().toLowerCase());
}
export function isLoopbackBindHost(host: string): boolean {
const normalized = host
.trim()
.toLowerCase()
.replace(/^\[(.*)\]$/, '$1');
if (normalized === 'localhost' || normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') {
return true;
}
if (isIP(normalized) === 4 && normalized.startsWith('127.')) {
return true;
}
return normalized.startsWith('::ffff:127.');
}
+295 -1
View File
@@ -295,6 +295,22 @@ class CodemanApp {
this.terminal = null;
this.fitAddon = null;
this.activeSessionId = null;
// ── Session detach / undock (beta) ───────────────────────────────────
// A "solo window" is a popped-out browser window showing exactly one
// session. Detected from the /session/:id URL path (robust even if a cached
// service-worker shell loads), with the server-injected global as a fallback.
this.soloSessionId = this._detectSoloSessionId();
this.isSoloWindow = !!this.soloSessionId;
this.detachedSessions = new Set(); // dashboard-side: ids currently popped out
this.detachedWindows = new Map(); // dashboard-side: id -> WindowProxy
this._detachWatchTimers = new Map(); // dashboard-side: id -> setInterval handle
this.windowChannel = null; // BroadcastChannel for cross-window sync
this._redockGrace = new Map(); // id -> timer: deferred redock (debounces popup reloads)
this._detachPingPending = null; // Set of ids awaiting a liveness answer
this._detachLivenessTimer = null; // periodic reconcile of channel-only detached windows
this._detachOrphanStrikes = new Map(); // id -> consecutive unanswered roll-calls (redock at 2)
this._initGeneration = 0; // dedup concurrent handleInit calls
this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive
this._selectGeneration = 0; // cancel stale selectSession loads
@@ -544,6 +560,11 @@ class CodemanApp {
init() {
// Initialize mobile detection first (adds device classes to body)
MobileDetection.init();
// Detach/undock: open the cross-window sync channel; if this is a solo
// (popped-out) window, apply its minimal chrome immediately so the tab
// strip never flashes before handleInit selects the target session.
this._initWindowChannel();
if (this.isSoloWindow) document.body.classList.add('solo-mode');
// Initialize mobile handlers
KeyboardHandler.init();
SwipeHandler.init();
@@ -776,6 +797,256 @@ class CodemanApp {
} catch { /* non-fatal */ }
}
// ══════════════════════════════════════════════════════════════════════
// Session detach / undock (beta/session-detach)
//
// Each detached window is just another normal client of the same session:
// the server already fans one PTY's output out to N SSE/WS clients and merges
// input from all of them, so a popped-out window is live with no extra server
// plumbing. The dashboard tracks which sessions are out, marks their tabs, and
// re-docks when the window closes. A BroadcastChannel keeps state in sync
// across windows (and survives a dashboard reload via roll-call).
// ══════════════════════════════════════════════════════════════════════
/** Resolve the solo session id from the URL path (preferred) or the
* server-injected global (fallback). Returns null for the normal dashboard. */
_detectSoloSessionId() {
try {
if (typeof window !== 'undefined' && typeof window.__CODEMAN_SOLO__ === 'string' && window.__CODEMAN_SOLO__) {
return window.__CODEMAN_SOLO__;
}
const m = location.pathname.match(/^\/session\/([^/]+)\/?$/);
return m ? decodeURIComponent(m[1]) : null;
} catch { return null; }
}
/**
* Pop a session out into its own browser window. SINGLE, idempotent entry
* point: the tab's pop-out icon calls this, and a future gesture layer
* ("pinch to drop") calls the exact same method — so keep it cheap and
* side-effect-light. Calling it again for an already-open window just raises
* that window.
* @param {string} id session id
*/
detachSession(id) {
if (this.isSoloWindow) return; // a solo window can't spawn more
if (!this.sessions.has(id)) return;
// Already detached → raise the existing popup instead of opening (or
// reloading) another. Mirrors the tab-click path: after a dashboard reload
// we hold no WindowProxy ref, so this raises via the channel rather than
// re-running window.open (which would reload the popup's terminal). Returns
// false only when we owned a now-closed window (re-dock + fall through to
// genuinely re-open below).
if (this.detachedSessions.has(id) && this._raiseDetached(id)) return;
const features = 'width=960,height=680,menubar=no,toolbar=no,location=no,status=no';
let win = null;
try { win = window.open('/session/' + encodeURIComponent(id), 'codeman-session-' + id, features); } catch {}
if (!win) {
this.showToast?.('Pop-out blocked — allow popups for this site to detach a session', 'error');
return;
}
this.detachedWindows.set(id, win);
this._markDetached(id, true);
this._watchDetachedWindow(id, win);
this._postWindowMessage({ type: 'detached', id });
try { win.focus(); } catch {}
}
/** Raise the popup for an already-detached session. Returns true if the raise
* was handled (caller should stop); false if we owned a now-closed window and
* re-docked it (caller should fall through to inline / re-open). Unifies the
* pop-out icon and tab-click paths so neither reloads a live popup. */
_raiseDetached(id) {
const win = this.detachedWindows.get(id);
if (win && !win.closed) { try { win.focus(); } catch {} return true; }
if (win && win.closed) { this._redock(id); return false; } // owned ref dead → redock + fall through
// No local ref (dashboard reloaded): assume alive and raise via the channel.
// A liveness ping (or the popup's own unload) heals the badge if it's gone.
this._postWindowMessage({ type: 'focus-request', id });
return true;
}
/** Re-dock a session: close its window (which re-docks via its unload
* announcement) and clear dashboard state now. */
redockSession(id) {
const win = this.detachedWindows.get(id);
if (win && !win.closed) { try { win.close(); } catch {} }
this._postWindowMessage({ type: 'close-request', id });
this._redock(id);
}
/** Clear all dashboard-side detached state/timers for a session. */
_redock(id) {
const t = this._detachWatchTimers.get(id);
if (t) { clearInterval(t); this._detachWatchTimers.delete(id); }
this._cancelPendingRedock(id);
this._detachOrphanStrikes.delete(id);
this.detachedWindows.delete(id);
this._markDetached(id, false);
}
/** Defer a channel-driven redock briefly. A popup *reload* emits 'redocked'
* then re-announces 'detached'; the grace window lets that re-announce cancel
* the redock, so a reload doesn't blip the dashboard badge. A real close
* leaves the redock unanswered and it fires. */
_scheduleRedock(id) {
if (this._redockGrace.has(id)) return;
const timer = setTimeout(() => { this._redockGrace.delete(id); this._redock(id); }, 1500);
this._redockGrace.set(id, timer);
}
_cancelPendingRedock(id) {
const t = this._redockGrace.get(id);
if (t) { clearTimeout(t); this._redockGrace.delete(id); }
}
/** Toggle the "detached" marker on a tab (immediate DOM update + state set).
* Full re-renders re-apply the class from this.detachedSessions. */
_markDetached(id, on) {
if (on) this.detachedSessions.add(id); else this.detachedSessions.delete(id);
const container = this.$('sessionTabs');
const tab = container && container.querySelector(`.session-tab[data-id="${id}"]`);
if (tab) tab.classList.toggle('detached', on);
}
/** Poll a window we opened; when it closes, re-dock its tab. This is the
* primary (reliable) close-detection path for windows this tab opened. */
_watchDetachedWindow(id, win) {
const prev = this._detachWatchTimers.get(id);
if (prev) clearInterval(prev);
const timer = setInterval(() => {
if (!win || win.closed) {
clearInterval(timer);
this._detachWatchTimers.delete(id);
this._redock(id);
}
}, 800);
this._detachWatchTimers.set(id, timer);
}
/** Open the cross-window BroadcastChannel and wire role-specific handlers. */
_initWindowChannel() {
if (typeof BroadcastChannel === 'undefined') return;
try { this.windowChannel = new BroadcastChannel('codeman-windows'); }
catch { this.windowChannel = null; return; }
this.windowChannel.onmessage = (e) => this._onWindowMessage(e.data);
if (this.isSoloWindow) {
// Announce presence so the dashboard marks this session's tab detached —
// even if this window was opened directly by URL rather than window.open.
this._postWindowMessage({ type: 'detached', id: this.soloSessionId });
// On close, tell the dashboard to re-dock. pagehide is the reliable signal
// on modern browsers; beforeunload is a belt-and-suspenders fallback.
const announceClose = () => this._postWindowMessage({ type: 'redocked', id: this.soloSessionId });
window.addEventListener('pagehide', announceClose);
window.addEventListener('beforeunload', announceClose);
} else {
// Dashboard: ask any already-open solo windows to re-announce themselves
// (covers a dashboard reload while popups remain open), then keep
// reconciling so a popup that died WITHOUT a 'redocked' (hard kill / crash)
// eventually un-marks its tab.
this._postWindowMessage({ type: 'roll-call' });
this._startDetachLiveness();
}
}
_postWindowMessage(msg) {
try { if (this.windowChannel) this.windowChannel.postMessage(msg); } catch {}
}
_onWindowMessage(msg) {
if (!msg || typeof msg !== 'object') return;
if (this.isSoloWindow) {
// Roll-call has no id (broadcast to all) — answer before the id filter.
if (msg.type === 'roll-call') { this._postWindowMessage({ type: 'detached', id: this.soloSessionId }); return; }
if (msg.id !== this.soloSessionId) return;
if (msg.type === 'close-request') { try { window.close(); } catch {} }
else if (msg.type === 'focus-request') { try { window.focus(); } catch {} }
return;
}
// Dashboard side.
if (msg.type === 'detached' && msg.id) {
this._cancelPendingRedock(msg.id); // a re-announce (e.g. popup reload) cancels a deferred redock
this._detachPingPending?.delete(msg.id); // and proves liveness for this tick
this._detachOrphanStrikes.delete(msg.id); // any answer clears accumulated misses
this._markDetached(msg.id, true);
} else if (msg.type === 'redocked' && msg.id) {
this._scheduleRedock(msg.id); // defer: a popup reload fires redocked→detached; grace avoids a badge blip
} else if (msg.type === 'detach-request' && msg.id) {
// Future gesture hook: another window asks the dashboard to detach a tab.
this.detachSession(msg.id);
}
}
/** Dashboard: periodically reconcile detached tabs we hold no window ref for
* (e.g. after a dashboard reload). Owned windows are covered by the
* win.closed poll; channel-only ones can only be checked by asking them to
* re-announce and re-docking any that stay silent. */
_startDetachLiveness() {
if (this._detachLivenessTimer) return;
this._detachLivenessTimer = setInterval(() => this._pingDetached(), 5000);
}
_pingDetached() {
const orphans = [];
for (const id of this.detachedSessions) {
const win = this.detachedWindows.get(id);
if (!win) orphans.push(id); // channel-only — must verify via re-announce
else if (win.closed) this._redock(id); // owned & closed — heal now
}
if (!orphans.length) return;
this._detachPingPending = new Set(orphans);
this._postWindowMessage({ type: 'roll-call' });
// Live popups answer 'detached' (clearing themselves above); survivors stay in
// the pending set. Redock only after TWO consecutive unanswered roll-calls — a
// backgrounded popup is timer-throttled and may miss a single 1.2s window, and
// we don't want to wrongly un-mark a still-open tab. A later answer resets the
// strike count (see _onWindowMessage).
setTimeout(() => {
if (!this._detachPingPending) return;
for (const id of this._detachPingPending) {
const strikes = (this._detachOrphanStrikes.get(id) || 0) + 1;
if (strikes >= 2) { this._detachOrphanStrikes.delete(id); this._redock(id); }
else this._detachOrphanStrikes.set(id, strikes);
}
this._detachPingPending = null;
}, 1200);
}
/** Solo window: select the target session and apply minimal single-session
* chrome. Called from handleInit once the session list has loaded. */
_applySoloMode() {
document.body.classList.add('solo-mode');
const session = this.sessions.get(this.soloSessionId);
if (!session) { this._showSoloSessionGone(); return; }
// Force re-select (handleInit cleared terminal state above).
this.activeSessionId = null;
this.selectSession(this.soloSessionId);
const name = this.getSessionName(session) || 'Session';
const titleEl = document.getElementById('soloSessionTitle');
if (titleEl) { titleEl.textContent = name; titleEl.style.display = ''; }
const redock = document.getElementById('soloRedockBtn');
if (redock) redock.style.display = '';
document.title = name + ' — Codeman';
if (this.notificationManager) this.notificationManager.originalTitle = document.title;
// Neutralize the dashboard-only brand click in a solo window.
const logo = document.querySelector('.header-brand .logo');
if (logo) logo.onclick = (e) => { e.preventDefault(); };
}
/** Solo window: the target session is gone (never existed, or ended while
* this window was open). Show a friendly terminal state. */
_showSoloSessionGone() {
document.body.classList.add('solo-mode');
if (document.querySelector('.solo-gone-overlay')) return;
const el = document.createElement('div');
el.className = 'solo-gone-overlay';
el.innerHTML = '<h2>Session unavailable</h2>'
+ '<p>This session has ended or is no longer available.</p>'
+ '<button class="btn-primary" onclick="window.close()">Close window</button>';
document.body.appendChild(el);
document.title = 'Session ended — Codeman';
}
connectSSE() {
// Check if browser is offline
if (!navigator.onLine) {
@@ -929,6 +1200,12 @@ class CodemanApp {
_onSessionDeleted(data) {
if (this._wsSessionId === data.id) this._disconnectWs();
// Solo window whose session just ended → show the "unavailable" state.
if (this.isSoloWindow && data.id === this.soloSessionId) {
this._showSoloSessionGone();
}
// Dashboard: a detached session ended → clear its detached state/timers.
if (this.detachedSessions.has(data.id)) this._redock(data.id);
this._cleanupSessionData(data.id);
if (this.activeSessionId === data.id) {
this.activeSessionId = null;
@@ -1949,6 +2226,14 @@ class CodemanApp {
// Reset activeSessionId so selectSession doesn't early-return.
// Guard: skip if a newer handleInit has already started (race between loadState + SSE init).
if (gen !== this._initGeneration) return;
// Solo (detached) window: always show exactly the target session, ignoring
// the dashboard's "restore last active" logic.
if (this.isSoloWindow) {
this._applySoloMode();
return;
}
const previousActiveId = this.activeSessionId;
this.activeSessionId = null;
if (this.sessionOrder.length > 0) {
@@ -2182,19 +2467,21 @@ class CodemanApp {
const tallTabsEnabled = this._tallTabsEnabled ?? false;
const showFolder = tallTabsEnabled && session.name && folderName && folderName !== name;
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)}"` : ''}>
parts.push(`<div class="session-tab ${isActive ? 'active' : ''}${alertClass}${this.detachedSessions.has(id) ? ' detached' : ''}" 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">
<span class="tab-name-row">
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : ''}
<span class="tab-name" data-session-id="${id}">${(() => { const p = parseSessionPrefix(name); return p && p.suffix ? '<span class="tab-prefix">' + escapeHtml(p.prefix) + '</span><span class="tab-suffix">: ' + escapeHtml(p.suffix) + '</span>' : escapeHtml(name); })()}</span>
<span class="tab-detached-badge" aria-hidden="true">detached</span>
</span>
${showFolder ? `<span class="tab-folder">\u{1F4C1} ${escapeHtml(folderName)}</span>` : ''}
</span>
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()" aria-label="${taskStats.running} running tasks">${taskStats.running}</span>` : ''}
${subagentBadge}
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('${escapeHtml(id)}')" title="Session options" aria-label="Session options" tabindex="0">&#x2699;</span>
<span class="tab-detach" onclick="event.stopPropagation(); app.detachSession('${escapeHtml(id)}')" title="Open in a new window" aria-label="Open session in a new window" tabindex="0">&#x29C9;</span>
<span class="tab-close" onclick="event.stopPropagation(); app.requestCloseSession('${escapeHtml(id)}')" title="Close session" aria-label="Close session" tabindex="0">&times;</span>
</div>`);
_tabIdx++;
@@ -2525,6 +2812,13 @@ class CodemanApp {
}
async selectSession(sessionId) {
// If this session is popped out into its own window, raise that window
// instead of showing it inline (focus-on-click for detached tabs).
if (!this.isSoloWindow && this.detachedSessions.has(sessionId)) {
// Raise the popup instead of showing inline. If we owned a now-closed
// window, _raiseDetached re-docks and returns false so we fall through.
if (this._raiseDetached(sessionId)) return;
}
if (this.activeSessionId === sessionId) return;
// Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors
// programmatic focus() within the user-gesture call stack (e.g. tab click).
File diff suppressed because one or more lines are too long
+30 -2
View File
@@ -2,6 +2,10 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<!-- Resolve all relative assets against the site root so the same shell can be
served at /session/:id (detached single-session window) without 404ing
on relative <script>/<link> URLs. Must precede the first resource tag. -->
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="description" content="Claude Code session manager with web interface">
<meta name="theme-color" content="#0a0a0a">
@@ -55,7 +59,9 @@
<div class="skeleton-toolbar"></div>
</div>
<!-- Skip link for keyboard users -->
<a href="#terminalContainer" class="skip-link">Skip to terminal</a>
<!-- onclick scrolls/focuses directly: with <base href="/"> a bare href="#..." would
navigate to /#... (the dashboard) from a /session/:id solo window. -->
<a href="#terminalContainer" class="skip-link" onclick="event.preventDefault(); var t=document.getElementById('terminalContainer'); if(t){t.scrollIntoView(); var f=t.querySelector('textarea,[tabindex]'); (f||t).focus&&(f||t).focus();}">Skip to terminal</a>
<div class="app">
<!-- Compact Header with Session Tabs -->
<header class="header">
@@ -67,7 +73,11 @@
<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs">
</div>
<!-- Detached single-session window title (shown only in solo mode) -->
<div class="solo-session-title" id="soloSessionTitle" style="display: none;" aria-live="polite"></div>
<div class="header-right">
<button class="btn-icon-header btn-solo-redock" id="soloRedockBtn" style="display: none;" onclick="window.close()" title="Re-dock to dashboard (close window)" aria-label="Re-dock session to dashboard">&#x229E;</button>
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
<span class="tunnel-dot"></span>
</button>
@@ -97,7 +107,8 @@
</div>
</div>
<button class="btn-icon-header btn-response-viewer-header" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications">
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
</button>
@@ -922,6 +933,16 @@
<span class="slider"></span>
</label>
</div>
<div class="settings-item settings-item-multiline" id="appSettingsGestureControlItem" title="Enable the camera hand-tracking gesture overlay (applied on reload). The instance must run with CODEMAN_GESTURE=1.">
<div class="settings-item-text">
<span class="settings-item-label">Gesture Control (beta)</span>
<span class="settings-item-desc">Camera hand-tracking overlay (applied on reload)</span>
</div>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsGestureControl">
<span class="slider"></span>
</label>
</div>
<!-- Header Displays Section -->
<div class="settings-section-header">Header Displays</div>
@@ -960,6 +981,13 @@
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show the multi-monitor button in the header (opens Codeman spanned across all displays)">
<span class="settings-item-label">Multi-monitor Button</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowMultiMonitorButton">
<span class="slider"></span>
</label>
</div>
<!-- Tab Bar Section -->
<div class="settings-section-header">Tab Bar</div>
+17
View File
@@ -3129,6 +3129,23 @@ Object.assign(CodemanApp.prototype, {
this.notificationManager?.toggleDrawer();
},
// Open a Codeman window stretched across all displays (multi-monitor mode).
// The server spawns scripts/span-codeman.sh, which launches a fresh, spanning
// browser --app window so in-page floating panels can cross the monitor seam.
async launchMultiMonitor() {
try {
const res = await fetch('/api/system/span-displays', { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (res.ok && data.success) {
this.showToast('Opening Codeman across all displays…', 'success');
} else {
this.showToast(data.error || 'Could not open spanning window', 'error');
}
} catch (err) {
this.showToast('Could not open spanning window: ' + (err?.message || err), 'error');
}
},
// Alias for showToast
toast(message, type = 'info') {
return this.showToast(message, type);
+42 -3
View File
@@ -312,6 +312,14 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false;
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? false;
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
// can't promise something that won't work.
const gestureItem = document.getElementById('appSettingsGestureControlItem');
if (gestureItem) gestureItem.style.display = window.__codemanGestureAvailable ? '' : 'none';
document.getElementById('appSettingsGestureControl').checked = settings.gestureControlEnabled ?? defaults.gestureControlEnabled ?? false;
document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? defaults.subagentTrackingEnabled ?? true;
document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? defaults.subagentActiveTabOnly ?? true;
document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? defaults.imageWatcherEnabled ?? false;
@@ -1107,6 +1115,9 @@ Object.assign(CodemanApp.prototype, {
},
async saveAppSettings() {
// Gesture overlay is injected at page render (server-side), so a change to it
// only takes effect on reload — remember the prior value to decide below.
const _prevGestureEnabled = (this.loadAppSettingsFromStorage().gestureControlEnabled ?? false) === true;
const settings = {
defaultClaudeMdPath: document.getElementById('appSettingsClaudeMdPath').value.trim(),
defaultWorkingDir: document.getElementById('appSettingsDefaultDir').value.trim(),
@@ -1121,6 +1132,8 @@ Object.assign(CodemanApp.prototype, {
showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked,
showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked,
showSubagents: document.getElementById('appSettingsShowSubagents').checked,
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
@@ -1273,6 +1286,18 @@ Object.assign(CodemanApp.prototype, {
}
this.closeAppSettings();
// The gesture overlay is injected at page render (server reads
// gestureControlEnabled from settings.json), so a change only takes effect on
// reload. Reload when it actually changed — the server PUT above already
// persisted the new value.
if (settings.gestureControlEnabled !== _prevGestureEnabled) {
this.showToast(
settings.gestureControlEnabled ? 'Enabling gesture control — reloading…' : 'Disabling gesture control — reloading…',
'info'
);
setTimeout(() => location.reload(), 400);
}
},
// Load model configuration from server for the settings modal
@@ -1374,6 +1399,9 @@ Object.assign(CodemanApp.prototype, {
showProjectInsights: false,
showFileBrowser: false,
showSubagents: false,
showMultiMonitorButton: false,
// Input
gestureControlEnabled: false,
// Feature toggles - keep tracking on even on mobile
subagentTrackingEnabled: true,
subagentActiveTabOnly: true, // Only show subagents for active tab
@@ -1445,13 +1473,24 @@ Object.assign(CodemanApp.prototype, {
lifecycleBtn.style.display = showLifecycleLog ? '' : 'none';
}
// Hide notification bell when notifications are disabled
const notifEnabled = this.notificationManager?.preferences?.enabled ?? true;
// Multi-monitor button — hidden by default (App Settings → Display → "Header
// Displays"). The server renders the correct initial state on every reload;
// this handles a live toggle from a settings save (no reload). Toggle the
// marker class (matches the server-side reveal) rather than an inline style.
const showMultiMonitorButton = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
const multiMonitorBtn = document.querySelector('.btn-multimonitor');
if (multiMonitorBtn) {
multiMonitorBtn.classList.toggle('btn-multimonitor--hidden', !showMultiMonitorButton);
}
// Notification bell is retired (notifications live in Settings → Notifications
// + the drawer); keep it hidden regardless of the notification-enabled state.
const notifBtn = document.querySelector('.btn-notifications');
if (notifBtn) {
notifBtn.style.display = notifEnabled ? '' : 'none';
notifBtn.style.display = 'none';
}
// Close the drawer if notifications got disabled while it's open
const notifEnabled = this.notificationManager?.preferences?.enabled ?? true;
if (!notifEnabled) {
const drawer = document.getElementById('notifDrawer');
if (drawer) drawer.classList.remove('open');
+98
View File
@@ -846,6 +846,13 @@ body {
transform: rotate(45deg);
}
/* Multi-monitor header button: hidden by default (opt-in via App Settings →
Display → "Header Displays"). The server strips this class at render when the
setting is on; the client toggles it live on save. */
.btn-multimonitor--hidden {
display: none !important;
}
.btn-icon-header.btn-settings {
width: 30px;
height: 30px;
@@ -905,6 +912,97 @@ body {
transform: rotate(45deg);
}
/* ===== Session detach / undock (beta/session-detach) ===================== */
/* Pop-out (detach) icon on each tab — mirrors .tab-gear reveal-on-hover. */
.session-tab .tab-detach {
opacity: 0;
width: 0;
padding: 0;
font-size: 0.95rem;
line-height: 1;
color: var(--text-muted);
cursor: pointer;
overflow: hidden;
transition: opacity 0.15s, width 0.15s, padding 0.15s;
}
.session-tab:hover .tab-detach {
opacity: 1;
width: auto;
padding: 0 0.3rem;
}
.session-tab .tab-detach:hover {
color: var(--accent-hover);
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
/* A tab whose session is popped out into its own window. */
.session-tab.detached {
opacity: 0.72;
}
.session-tab.detached .tab-detach {
/* Keep the pop-out icon visible while detached as a re-focus affordance. */
opacity: 1;
width: auto;
padding: 0 0.3rem;
color: var(--accent-hover);
}
.session-tab .tab-detached-badge {
display: none;
align-items: center;
font-size: 0.55rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 1px 4px;
margin-left: 4px;
border-radius: 3px;
background: rgba(96, 165, 250, 0.18);
color: var(--accent-hover);
}
.session-tab.detached .tab-detached-badge {
display: inline-flex;
}
/* ===== Solo (detached single-session) window chrome ===================== */
body.solo-mode .session-tabs,
body.solo-mode .header-system-stats,
body.solo-mode .header-tokens,
body.solo-mode .btn-notifications,
body.solo-mode .btn-multimonitor,
body.solo-mode .btn-lifecycle-log {
display: none !important;
}
.solo-session-title {
flex: 1;
min-width: 0;
font-weight: 600;
font-size: 13px;
color: var(--text);
padding: 0 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* "Session unavailable" overlay for a solo window whose session has ended. */
.solo-gone-overlay {
position: fixed;
inset: 0;
z-index: 5000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
background: rgba(9, 9, 11, 0.92);
color: var(--text);
text-align: center;
padding: 24px;
}
.solo-gone-overlay h2 { margin: 0; font-size: 18px; }
.solo-gone-overlay p { margin: 0; color: var(--text-muted); font-size: 13px; }
/* Mode indicator on session tab */
.session-tab .tab-mode {
font-size: 0.6rem;
+4 -2
View File
@@ -16,10 +16,12 @@ import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.j
import { SseEvent } from './sse-events.js';
import type { SessionPort } from './ports/session-port.js';
import type { EventPort } from './ports/event-port.js';
import { dataPath } from '../config/instance.js';
// Shared path constants used across route modules
// Shared path constants used across route modules. CASES_DIR (project folders)
// stays shared across instances; SETTINGS_PATH is per-instance runtime state.
export const CASES_DIR = join(homedir(), 'codeman-cases');
export const SETTINGS_PATH = join(homedir(), '.codeman', 'settings.json');
export const SETTINGS_PATH = dataPath('settings.json');
/**
* Validates that a path component doesn't escape the base directory.
+4 -3
View File
@@ -17,8 +17,9 @@ import { writeHooksConfig } from '../../hooks-config.js';
import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
import { SseEvent } from '../sse-events.js';
import type { EventPort, ConfigPort } from '../ports/index.js';
import { dataPath, getDataDir } from '../../config/instance.js';
const LINKED_CASES_FILE = join(homedir(), '.codeman', 'linked-cases.json');
const LINKED_CASES_FILE = dataPath('linked-cases.json');
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
@@ -151,7 +152,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
// Save the linked case
linkedCases[name] = expandedPath;
try {
const codemanDir = join(homedir(), '.codeman');
const codemanDir = getDataDir();
if (!existsSync(codemanDir)) {
mkdirSync(codemanDir, { recursive: true });
}
@@ -206,7 +207,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const { order } = parseBody(CaseOrderSchema, req.body, 'Invalid order data');
try {
const dir = join(homedir(), '.codeman');
const dir = getDataDir();
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
+113 -7
View File
@@ -4,7 +4,8 @@
*/
import { FastifyInstance } from 'fastify';
import { join } from 'node:path';
import { basename as pathBasename, join } from 'node:path';
import { homedir } from 'node:os';
import fs from 'node:fs/promises';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { fileStreamManager } from '../../file-stream-manager.js';
@@ -278,7 +279,6 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
ico: 'image/x-icon',
bmp: 'image/bmp',
mp4: 'video/mp4',
@@ -292,19 +292,21 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
};
const content = await fs.readFile(resolvedPath);
if (download === 'true') {
const rawBasename = filePath!.split('/').pop() || 'download';
// Sanitize filename for Content-Disposition header (prevent header injection)
const basename = rawBasename.replace(/["\\\r\n]/g, '_');
const rawBasename = filePath!.split('/').pop() || 'download';
// Sanitize filename for Content-Disposition header (prevent header injection)
const basename = rawBasename.replace(/["\\\r\n]/g, '_');
if (download === 'true' || ext === 'svg') {
reply.raw.writeHead(200, {
'Content-Type': mimeTypes[ext] || 'application/octet-stream',
'Content-Type': ext === 'svg' ? 'application/octet-stream' : mimeTypes[ext] || 'application/octet-stream',
'Content-Disposition': `attachment; filename="${basename}"`,
'Content-Length': content.length,
'X-Content-Type-Options': 'nosniff',
});
reply.raw.end(content);
return;
}
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
reply.header('X-Content-Type-Options', 'nosniff');
reply.send(content);
} catch (err) {
reply
@@ -380,4 +382,108 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void
const closed = fileStreamManager.closeStream(streamId);
return { success: closed };
});
// Session-scoped file download.
// Uses the same realpath-based workspace boundary as file preview/raw routes;
// the sensitive-path blocklist remains defense-in-depth, not the primary boundary.
const SENSITIVE_PATTERNS: RegExp[] = [
/^\/etc\/shadow$/,
/^\/etc\/gshadow$/,
/^\/etc\/master\.passwd$/,
new RegExp(`^${homedir().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/\\.ssh\\/`),
/\/\.env$/,
/\/\.env\./,
/\/credentials(\.json|\.yml|\.yaml|\.xml)?$/i,
/\/\.aws\/credentials$/,
/\/\.gcloud\/credentials\.db$/,
/\/\.docker\/config\.json$/,
];
function isSensitivePath(absPath: string): boolean {
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(absPath));
}
app.get('/api/download', async (req, reply) => {
const { path: filePath, sessionId } = req.query as { path?: string; sessionId?: string };
if (!filePath) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
return;
}
if (!sessionId) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing sessionId parameter'));
return;
}
const session = findSessionOrFail(ctx, sessionId);
const validated = validateSessionFilePath(session.workingDir, filePath);
if (!validated) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'));
return;
}
const { resolvedPath } = validated;
// Check sensitive path blocklist
if (isSensitivePath(resolvedPath)) {
reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'));
return;
}
try {
const stat = await fs.stat(resolvedPath);
if (!stat.isFile()) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path is not a file'));
return;
}
// 50MB size limit
const MAX_DOWNLOAD_SIZE = 50 * 1024 * 1024;
if (stat.size > MAX_DOWNLOAD_SIZE) {
reply
.code(400)
.send(
createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`File too large (${Math.round(stat.size / 1024 / 1024)}MB > 50MB limit)`
)
);
return;
}
const ext = filePath.split('.').pop()?.toLowerCase() || '';
const mimeTypes: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
pdf: 'application/pdf',
json: 'application/json',
txt: 'text/plain',
md: 'text/markdown',
csv: 'text/csv',
xml: 'application/xml',
zip: 'application/zip',
gz: 'application/gzip',
tar: 'application/x-tar',
};
const filename = pathBasename(resolvedPath);
const content = await fs.readFile(resolvedPath);
// Bypass Fastify compression — write directly to raw response
reply.raw.writeHead(200, {
'Content-Type': mimeTypes[ext] || 'application/octet-stream',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': content.length,
});
reply.raw.end(content);
return;
} catch (err) {
reply
.code(500)
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
}
});
}
+2 -1
View File
@@ -54,9 +54,10 @@ import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
import { RunSummaryTracker } from '../../run-summary.js';
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
import { dataPath } from '../../config/instance.js';
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
const LINKED_CASES_FILE = join(homedir(), '.codeman', 'linked-cases.json');
const LINKED_CASES_FILE = dataPath('linked-cases.json');
// Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation)
// eslint-disable-next-line no-control-regex
+53 -5
View File
@@ -6,11 +6,13 @@
import { FastifyInstance } from 'fastify';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
import fs from 'node:fs/promises';
import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os';
import { execSync } from 'node:child_process';
import { totalmem, freemem, loadavg, cpus } from 'node:os';
import { execSync, spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { dataPath } from '../../config/instance.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
import {
ConfigUpdateSchema,
@@ -41,7 +43,7 @@ import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js';
// Maximum screenshot upload size (10MB)
const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
// Screenshots directory
const SCREENSHOTS_DIR = join(homedir(), '.codeman', 'screenshots');
const SCREENSHOTS_DIR = dataPath('screenshots');
/** Cached CPU count — doesn't change at runtime */
const CPU_COUNT = cpus().length;
@@ -92,12 +94,24 @@ function getSystemStats(): {
}
}
/**
* Build the URL the spanning browser window should open, pinned to localhost.
* Takes only a digits-only port from the (untrusted) Host header so nothing
* attacker-controllable reaches the launched browser; falls back to the default
* port when the header is absent/odd. Exported for unit testing.
*/
export function resolveSpanUrl(hostHeader: string | undefined, fallbackPort = '3000'): string {
const hostPort = String(hostHeader ?? '').split(':')[1] ?? '';
const port = /^\d+$/.test(hostPort) ? hostPort : fallbackPort;
return `http://localhost:${port}`;
}
export function registerSystemRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
): void {
const windowStatesPath = join(homedir(), '.codeman', 'subagent-window-states.json');
const parentMapPath = join(homedir(), '.codeman', 'subagent-parents.json');
const windowStatesPath = dataPath('subagent-window-states.json');
const parentMapPath = dataPath('subagent-parents.json');
// ═══════════════════════════════════════════════════════════════
// System Status & Health
@@ -239,6 +253,40 @@ export function registerSystemRoutes(
return { success: true };
});
// ═══════════════════════════════════════════════════════════════
// Multi-monitor: span Codeman across all displays
// ═══════════════════════════════════════════════════════════════
// Spawn scripts/span-codeman.sh, which opens a fresh, maximized browser --app
// window sized to the union of all displays — so in-page floating session
// panels can be dragged across the physical monitor seam. macOS only; needs
// the one-time "Displays have separate Spaces" OFF prerequisite (see script).
app.post('/api/system/span-displays', async (req, reply) => {
// macOS only: the launcher uses osascript + Finder desktop bounds and Chrome
// --app geometry flags. Fail clearly elsewhere instead of spawning a bash
// that errors out invisibly (the toast would otherwise lie "Opening…").
if (process.platform !== 'darwin') {
return reply
.code(400)
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Multi-monitor spanning is only supported on macOS.'));
}
// Resolve the bundled launcher relative to this module (works from src/ and dist/).
const scriptPath = join(dirname(fileURLToPath(import.meta.url)), '../../../scripts/span-codeman.sh');
if (!existsSync(scriptPath)) {
return reply.code(500).send(createErrorResponse(ApiErrorCode.INTERNAL_ERROR, 'span-codeman.sh not found'));
}
// Point the spanning window at THIS server (localhost + sanitized port).
const url = resolveSpanUrl(req.headers.host);
try {
const child = spawn('bash', [scriptPath, url], { detached: true, stdio: 'ignore' });
child.on('error', (err) => app.log.error({ err }, 'span-displays launch failed'));
child.unref();
return { success: true, url };
} catch (err) {
return reply.code(500).send(createErrorResponse(ApiErrorCode.INTERNAL_ERROR, getErrorMessage(err)));
}
});
// ═══════════════════════════════════════════════════════════════
// CLI Integrations (OpenCode)
// ═══════════════════════════════════════════════════════════════
+3
View File
@@ -293,6 +293,9 @@ export const SettingsUpdateSchema = z
showProjectInsights: z.boolean().optional(),
showFileBrowser: z.boolean().optional(),
showSubagents: z.boolean().optional(),
showMultiMonitorButton: z.boolean().optional(),
// Input
gestureControlEnabled: z.boolean().optional(),
// Claude CLI settings
claudeMode: z.string().max(50).optional(),
allowedTools: z.string().max(2000).optional(),
+166 -27
View File
@@ -36,10 +36,11 @@ 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';
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync, statSync } from 'node:fs';
import fs from 'node:fs/promises';
import { execSync } from 'node:child_process';
import { homedir, hostname as getHostname } from 'node:os';
import { hostname as getHostname } from 'node:os';
import { dataPath } from '../config/instance.js';
import { EventEmitter } from 'node:events';
import { Session, type BackgroundTask } from '../session.js';
import type { ClaudeMode, SessionState } from '../types.js';
@@ -101,6 +102,7 @@ import { SseEvent } from './sse-events.js';
import type { ScheduledRun } from './ports/index.js';
import { registerAuthMiddleware, registerSecurityHeaders } from './middleware/auth.js';
import { installRouteErrorHandler } from './route-error-handler.js';
import { isExplicitlyEnabled, isLoopbackBindHost } from './network-auth-policy.js';
import {
registerPushRoutes,
registerTeamRoutes,
@@ -146,7 +148,7 @@ import {
* Certs are stored in ~/.codeman/certs/ and reused across restarts.
*/
function getOrCreateSelfSignedCert(): { key: string; cert: string } {
const certsDir = join(homedir(), '.codeman', 'certs');
const certsDir = dataPath('certs');
const keyPath = join(certsDir, 'server.key');
const certPath = join(certsDir, 'server.crt');
@@ -190,6 +192,7 @@ export class WebServer extends EventEmitter {
private sse: SseStreamManager;
private store = getStore();
private port: number;
private host: string;
private https: boolean;
private testMode: boolean;
private mux: TerminalMultiplexer;
@@ -231,6 +234,10 @@ 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 readonly titleHostname: string;
private readonly windowTitle: string;
private readonly indexHtmlTemplate: string;
private readonly allowUnauthenticatedNetwork: boolean;
private _pasteImageGcStop: (() => void) | null = null;
private _eventLoopMonitor: EventLoopMonitorHandle | null = null;
private teamWatcherHandlers: {
@@ -239,15 +246,22 @@ export class WebServer extends EventEmitter {
teamRemoved: (config: unknown) => void;
taskUpdated: (data: unknown) => void;
} | null = null;
private readonly titleHostname: string;
private readonly windowTitle: string;
private readonly indexHtmlTemplate: string;
constructor(port: number = 3000, https: boolean = false, testMode: boolean = false, titleHostname?: string) {
constructor(
port: number = 3000,
https: boolean = false,
testMode: boolean = false,
host: string = '127.0.0.1',
titleHostname?: string,
allowUnauthenticatedNetwork: boolean = false
) {
super();
this.setMaxListeners(0);
this.host = host;
this.port = port;
this.https = https;
this.testMode = testMode;
this.allowUnauthenticatedNetwork =
allowUnauthenticatedNetwork || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK);
this.titleHostname = titleHostname || getHostname();
this.windowTitle = `codeman:${this.titleHostname}`;
this.indexHtmlTemplate = readFileSync(join(__dirname, 'public', 'index.html'), 'utf-8');
@@ -557,10 +571,30 @@ export class WebServer extends EventEmitter {
// Security headers + CORS
registerSecurityHeaders(this.app, this.https);
this.app.get('/', async (_req, reply) => {
return reply.header('Cache-Control', 'no-cache').type('text/html; charset=utf-8').send(this.renderIndexHtml());
return reply
.header('Cache-Control', 'no-cache')
.type('text/html; charset=utf-8')
.send(await this.renderIndexHtml());
});
this.app.get('/index.html', async (_req, reply) => {
return reply.header('Cache-Control', 'no-cache').type('text/html; charset=utf-8').send(this.renderIndexHtml());
return reply
.header('Cache-Control', 'no-cache')
.type('text/html; charset=utf-8')
.send(await this.renderIndexHtml());
});
// Detached single-session window (undock). Serves the same SPA shell but
// flags the client into "solo mode" for one session. Auth applies normally
// (the popup carries the dashboard's cookie on navigation). We serve 200
// even for an unknown id — the client renders a friendly "session
// unavailable" state, which also covers a session that ends while its
// detached window is still open. Registered before the static plugin so the
// explicit route wins over the '/' static prefix.
this.app.get('/session/:id', async (req, reply) => {
const { id } = req.params as { id: string };
return reply
.header('Cache-Control', 'no-cache')
.type('text/html; charset=utf-8')
.send(await this.renderIndexHtml(id));
});
// Service worker must never be cached — browsers check for SW updates on navigation
this.app.get('/sw.js', async (_req, reply) => {
@@ -980,11 +1014,100 @@ export class WebServer extends EventEmitter {
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
}
private renderIndexHtml(): string {
return this.indexHtmlTemplate.replace(
private async renderIndexHtml(soloSessionId?: string): Promise<string> {
let html = this.indexHtmlTemplate.replace(
'<title>Codeman</title>',
`<title>${escapeHtmlText(this.windowTitle)}</title>`
);
// Cache-bust same-origin module scripts + stylesheets so a normal reload
// always serves the latest (static assets carry a 1-year immutable cache).
html = this.cacheBustAssets(html);
// Per-user App-Settings flags, read server-side so the page renders in the
// right initial state on every normal reload (the client apply* functions
// only run on save). Read FRESH (bypass the 2s cache): a setting toggled
// moments ago triggers a reload here, and the cached value would render the
// pre-toggle state (e.g. the gesture bundle wouldn't inject until a 2nd
// reload). Skipped for solo popups (their header differs).
const settings: Record<string, unknown> = soloSessionId ? {} : await this.readSettings(true);
// Multi-monitor header button: carries the `btn-multimonitor--hidden` class
// in the template by default (App Settings → Display → "Header Displays");
// reveal by stripping that class when the user enabled it. Matching a unique
// class token (not user-facing copy) keeps this robust against template edits.
if (settings.showMultiMonitorButton === true) {
html = html.replace(' btn-multimonitor--hidden', '');
}
// Detached single-session ("solo") window: inject the target session id so
// the client can enter solo mode even if a (network-first) service worker
// later serves a cached shell. The client primarily detects solo mode from
// the /session/:id URL path; this global is a belt-and-suspenders fallback.
// The id is gated to JSON + <-escaped so it can't break out of the inline
// <script> (ids are UUIDs in practice, but defense-in-depth is cheap).
if (soloSessionId) {
const safeId = JSON.stringify(soloSessionId).replace(/</g, '\\u003c');
html = html.replace('</head>', `<script>window.__CODEMAN_SOLO__=${safeId};</script>\n</head>`);
}
// Gesture-control overlay (Phase 5): dashboard only (not solo popups, which
// have no tab strip). `CODEMAN_GESTURE=1` makes the feature *available* on
// this instance (it also widens CSP + serves the assets); the per-user
// `gestureControlEnabled` setting (App Settings → Input, default OFF) is the
// actual on/off. We expose `__codemanGestureAvailable` so the settings UI can
// show the toggle only when the feature is available, and inject the bundle
// (served same-origin from /gesture/, so 'self' covers it) only when enabled.
if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
html = html.replace('</head>', `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
if (settings.gestureControlEnabled === true) {
const v = this.gestureBundleVersion();
html = html.replace(
'</head>',
`<script type="module" src="/gesture/gesture-codeman.js${v}"></script>\n</head>`
);
}
}
return html;
}
/** mtime memo for asset cache-busting (keyed by absolute path). A full index
* render does one stat per script/link tag (~25-30); without this each `/`,
* `/index.html` and `/session/:id` hit would re-stat them all. A 1s TTL keeps
* a burst of renders cheap while still picking up an edited/redeployed file
* within a second (no server restart needed). */
private _assetVersionMemo = new Map<string, { v: number; ts: number }>();
private assetVersion(absPath: string): number | null {
const now = Date.now();
const hit = this._assetVersionMemo.get(absPath);
if (hit && now - hit.ts < 1000) return hit.v;
try {
const v = Math.floor(statSync(absPath).mtimeMs);
this._assetVersionMemo.set(absPath, { v, ts: now });
return v;
} catch {
return null;
}
}
/** Cache-busting query for the gesture bundle: its mtime (memoized, see
* assetVersion). The bundle is served from /gesture/ with a 1-year cache, so
* without a version that changes on redeploy the browser would keep running a
* stale bundle forever. Empty string if the file is missing. */
private gestureBundleVersion(): string {
const v = this.assetVersion(join(__dirname, 'public', 'gesture', 'gesture-codeman.js'));
return v === null ? '' : `?v=${v}`;
}
/** Append ?v=<mtime> to every same-origin .js/.css reference in the page so a
* normal reload always serves the latest. Codeman's static assets are sent
* with `Cache-Control: max-age=1y, immutable` and the script/link tags carry
* no version, so without this an edited module (panels-ui.js, styles.css, …)
* stays cached until a manual hard refresh. mtime is memoized (1s TTL) so a
* changed file is picked up with no server restart. External URLs (have a
* `:` scheme), already-versioned refs (have a `?`), and refs with no matching
* file on disk are left untouched. */
private cacheBustAssets(html: string): string {
const publicDir = join(__dirname, 'public');
return html.replace(/(\s(?:src|href)=")([^"?:]+\.(?:js|css))(")/g, (full, pre, ref, post) => {
const v = this.assetVersion(join(publicDir, ref));
return v === null ? full : `${pre}${ref}?v=${v}${post}`;
});
}
private async setupSessionListeners(session: Session): Promise<void> {
@@ -1088,7 +1211,7 @@ export class WebServer extends EventEmitter {
// Helper to get custom CLAUDE.md template path from settings
private async getDefaultClaudeMdPath(): Promise<string | undefined> {
const settingsPath = join(homedir(), '.codeman', 'settings.json');
const settingsPath = dataPath('settings.json');
try {
const content = await fs.readFile(settingsPath, 'utf-8');
@@ -1106,13 +1229,16 @@ export class WebServer extends EventEmitter {
// Read ~/.codeman/settings.json once and return the parsed object.
// Cached for 2s to avoid redundant reads during session creation bursts.
// The settings PUT route writes the file without invalidating this cache, so
// callers that must observe a just-saved value (e.g. renderIndexHtml on a
// post-save reload) pass forceFresh=true to bypass the cache.
private _settingsCache: { data: Record<string, unknown>; ts: number } | null = null;
private async readSettings(): Promise<Record<string, unknown>> {
private async readSettings(forceFresh = false): Promise<Record<string, unknown>> {
const now = Date.now();
if (this._settingsCache && now - this._settingsCache.ts < 2000) {
if (!forceFresh && this._settingsCache && now - this._settingsCache.ts < 2000) {
return this._settingsCache.data;
}
const settingsPath = join(homedir(), '.codeman', 'settings.json');
const settingsPath = dataPath('settings.json');
try {
const content = await fs.readFile(settingsPath, 'utf-8');
const data = JSON.parse(content) as Record<string, unknown>;
@@ -1533,6 +1659,13 @@ export class WebServer extends EventEmitter {
}
async start(): Promise<void> {
if (!isLoopbackBindHost(this.host) && !process.env.CODEMAN_PASSWORD && !this.allowUnauthenticatedNetwork) {
throw new Error(
'Refusing to start Codeman on a non-loopback host without CODEMAN_PASSWORD. ' +
'Set CODEMAN_PASSWORD or explicitly allow unauthenticated network access.'
);
}
await this.setupRoutes();
const lifecycleLog = getLifecycleLog();
@@ -1559,19 +1692,23 @@ export class WebServer extends EventEmitter {
this._eventLoopMonitor = startEventLoopMonitor();
}
await this.app.listen({ port: this.port, host: '0.0.0.0' });
await this.app.listen({ port: this.port, host: this.host });
const protocol = this.https ? 'https' : 'http';
console.log(`Codeman web interface running at ${protocol}://localhost:${this.port}`);
const displayHost = this.host === '0.0.0.0' ? 'localhost' : this.host;
console.log(`Codeman web interface running at ${protocol}://${displayHost}:${this.port}`);
// Security warning: server binds to 0.0.0.0 (all interfaces) — warn if no auth configured
if (!process.env.CODEMAN_PASSWORD) {
if (!isLoopbackBindHost(this.host) && !process.env.CODEMAN_PASSWORD && this.allowUnauthenticatedNetwork) {
console.warn('\n⚠ WARNING: No CODEMAN_PASSWORD set — server is accessible without authentication.');
console.warn(' Anyone on your network can access and control Claude sessions.');
console.warn(' Set CODEMAN_PASSWORD environment variable to enable auth.\n');
console.warn(
' This was explicitly allowed by --allow-unauthenticated-network or CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK.\n'
);
}
// Set API URL for child processes (MCP server, spawned sessions)
process.env.CODEMAN_API_URL = `${protocol}://localhost:${this.port}`;
const apiHost =
this.host === '0.0.0.0' || this.host === 'localhost' || this.host === '::1' ? '127.0.0.1' : this.host;
process.env.CODEMAN_API_URL = `${protocol}://${apiHost}:${this.port}`;
// Start scheduled runs cleanup timer
this.cleanup.setInterval(
@@ -1619,7 +1756,7 @@ export class WebServer extends EventEmitter {
// Tunnel only starts when user clicks the toggle in the UI — never on boot.
// Reset persisted tunnelEnabled so the UI toggle reflects actual state.
if (await this.isTunnelEnabled()) {
const settingsPath = join(homedir(), '.codeman', 'settings.json');
const settingsPath = dataPath('settings.json');
try {
const content = await fs.readFile(settingsPath, 'utf-8');
const settings = JSON.parse(content);
@@ -1640,7 +1777,7 @@ export class WebServer extends EventEmitter {
* Check if subagent tracking is enabled in settings (default: true)
*/
private async isSubagentTrackingEnabled(): Promise<boolean> {
const settingsPath = join(homedir(), '.codeman', 'settings.json');
const settingsPath = dataPath('settings.json');
try {
const content = await fs.readFile(settingsPath, 'utf-8');
const settings = JSON.parse(content);
@@ -1658,7 +1795,7 @@ export class WebServer extends EventEmitter {
* Check if image watcher is enabled in settings (default: false)
*/
private async isImageWatcherEnabled(): Promise<boolean> {
const settingsPath = join(homedir(), '.codeman', 'settings.json');
const settingsPath = dataPath('settings.json');
try {
const content = await fs.readFile(settingsPath, 'utf-8');
const settings = JSON.parse(content);
@@ -1676,7 +1813,7 @@ export class WebServer extends EventEmitter {
* Check if Cloudflare tunnel is enabled in settings (default: false)
*/
private async isTunnelEnabled(): Promise<boolean> {
const settingsPath = join(homedir(), '.codeman', 'settings.json');
const settingsPath = dataPath('settings.json');
try {
const content = await fs.readFile(settingsPath, 'utf-8');
const settings = JSON.parse(content);
@@ -2063,9 +2200,11 @@ export async function startWebServer(
port: number = 3000,
https: boolean = false,
testMode: boolean = false,
titleHostname?: string
host: string = '127.0.0.1',
titleHostname?: string,
allowUnauthenticatedNetwork: boolean = false
): Promise<WebServer> {
const server = new WebServer(port, https, testMode, titleHostname);
const server = new WebServer(port, https, testMode, host, titleHostname, allowUnauthenticatedNetwork);
await server.start();
return server;
}