merge master into claude-response-viewer-normalization

Only CLAUDE.md conflicted: master restructured it into the short-rule +
docs/architecture-invariants.md pointer layout while this PR was open.
The response-viewer detail now lives in architecture-invariants, so the
Claude turn-grouping and restored-placeholder rebind notes moved there.
Changeset rewritten to record the measured effect on real transcripts.
This commit is contained in:
Codeman maintainer
2026-07-28 11:04:41 +02:00
61 changed files with 6402 additions and 438 deletions
+49
View File
@@ -0,0 +1,49 @@
/**
* Limits and timeouts for web tabs (dashboards embedded as Codeman tabs).
*
* Every value here bounds something an untrusted-ish upstream controls: how many
* dashboards can be saved, how long the server will wait on one, how much of a
* response it will buffer before rewriting HTML, and how many sockets a single
* dashboard may hold open. Env-overridable in the same style as the other config
* modules.
*/
function envInt(name: string, fallback: number): number {
const parsed = parseInt(process.env[name] || '', 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
/** Max saved webviews (per owner in multi-user mode). */
export const MAX_WEBVIEWS = envInt('CODEMAN_MAX_WEBVIEWS', 50);
/**
* Max iframes kept mounted at once. Switching tabs must not reload a dashboard,
* so frames stay alive while hidden; past this many, the least-recently-viewed
* frame is evicted. Consumed by the frontend via `GET /api/webviews`.
*/
export const MAX_LIVE_WEBVIEW_FRAMES = envInt('CODEMAN_MAX_LIVE_WEBVIEW_FRAMES', 6);
/** How long a minted proxy capability stays valid (rolling, refreshed on use). */
export const WEBVIEW_CAPABILITY_TTL_MS = envInt('CODEMAN_WEBVIEW_CAPABILITY_TTL_MS', 12 * 60 * 60 * 1000);
/** Max concurrent capabilities held in memory before the oldest are dropped. */
export const MAX_WEBVIEW_CAPABILITIES = 200;
/** Upstream request timeout for a proxied HTTP request. */
export const WEBVIEW_UPSTREAM_TIMEOUT_MS = envInt('CODEMAN_WEBVIEW_TIMEOUT_MS', 30_000);
/** Shorter timeout for the editor's "Test" probe, which a human is waiting on. */
export const WEBVIEW_PROBE_TIMEOUT_MS = envInt('CODEMAN_WEBVIEW_PROBE_TIMEOUT_MS', 8_000);
/**
* Max bytes of an HTML response buffered for `<base>` injection and link
* rewriting. Larger HTML documents stream through untouched: the rewrite is a
* convenience, and buffering an unbounded upstream body is a memory hazard.
*/
export const MAX_WEBVIEW_HTML_REWRITE_BYTES = envInt('CODEMAN_MAX_WEBVIEW_HTML_BYTES', 8 * 1024 * 1024);
/** Max concurrent proxied WebSockets per webview (mirrors MAX_WS_PER_SESSION). */
export const MAX_WEBVIEW_SOCKETS = envInt('CODEMAN_MAX_WEBVIEW_SOCKETS', 8);
/** URL path prefix the proxy is mounted at. Single source of truth. */
export const WEBVIEW_PROXY_PREFIX = '/webview';
+29
View File
@@ -9,6 +9,7 @@
* - CleanupRegistration / CleanupResourceType — entries for the centralized CleanupManager
* - NiceConfig / DEFAULT_NICE_CONFIG — process priority settings for `nice`/`ionice`
* - ProcessStats — memory/CPU/child-count snapshot for resource monitoring
* - FilesystemBrowseData — bounded path-picker directory listing returned to the web UI
*/
/**
@@ -68,6 +69,34 @@ export interface ProcessStats {
updatedAt: number;
}
/** A selectable entry returned by the filesystem path-picker API. */
export type FilesystemPreviewKind = 'image' | 'text' | 'document';
export interface FilesystemBrowseEntry {
name: string;
path: string;
type: 'file' | 'directory';
size?: number;
symlink?: boolean;
previewKind?: FilesystemPreviewKind;
}
/** A named root the path picker may browse without escaping its allowlist. */
export interface FilesystemBrowseRoot {
label: string;
path: string;
}
/** Response payload for `GET /api/filesystem/browse`. */
export interface FilesystemBrowseData {
path: string;
parent: string | null;
root: string;
roots: FilesystemBrowseRoot[];
entries: FilesystemBrowseEntry[];
truncated: boolean;
}
export type CleanupResourceType = 'timer' | 'interval' | 'watcher' | 'listener' | 'stream';
/**
+1
View File
@@ -70,3 +70,4 @@ export * from './update.js';
export * from './workflow-run.js';
export * from './search.js';
export * from './user.js';
export * from './webview.js';
+87
View File
@@ -0,0 +1,87 @@
/**
* @fileoverview Web tab (dashboard) types.
*
* A "webview" is a saved URL that Codeman renders as a tab alongside agent
* sessions: Grafana on :3000, a Uptime-Kuma on :4000, an internal status page.
* It is deliberately NOT a sixth `SessionMode`, it has no PTY, no tmux, no
* respawn and no idle detection. Same reasoning that keeps Docker and remote-SSH
* as case overlays rather than modes.
*
* Key exports:
* - Webview, the persisted record (`~/.codeman/webviews.json`).
* - WebviewEmbedMode, 'proxy' (served through Codeman's origin) or 'direct'
* (a plain cross-origin iframe, only viable for HTTPS targets that allow framing).
* - WebviewProbe, the result of the server-side reachability/framing probe.
* - WebviewOpenData, what `POST /api/webviews/:id/open` hands the browser.
*
* No I/O here. Persistence lives in `src/webview-store.ts`, capability minting in
* `src/webview-capabilities.ts`, the proxy helpers in `src/web/webview-proxy.ts`.
*/
/**
* How the browser should embed a webview.
*
* - `proxy`: the iframe points at `/webview/<capability>/` on Codeman's own
* origin and the server relays to the target. Required whenever the target is
* plain HTTP (an HTTPS Codeman page cannot embed it: mixed content) or refuses
* framing via `X-Frame-Options` / `frame-ancestors`.
* - `direct`: the iframe points at the target URL itself. Cheaper, but only works
* for HTTPS targets that permit framing, and needs the target origin added to
* the page CSP's `frame-src`.
*/
export type WebviewEmbedMode = 'proxy' | 'direct';
/** A saved dashboard, persisted to `~/.codeman/webviews.json`. */
export interface Webview {
id: string;
/** Display name shown on the tab. */
name: string;
/** Absolute target URL. `http:` / `https:` only, never with embedded credentials. */
url: string;
/** Optional single-glyph tab icon (emoji or letter). */
icon?: string;
/** Default embed strategy for this dashboard. */
embedMode: WebviewEmbedMode;
/**
* When false (the default) the iframe is sandboxed WITHOUT `allow-same-origin`,
* so a proxied page runs in an opaque origin and cannot read the Codeman page or
* call its API. Setting this to true trades that isolation for the page's own
* cookies/localStorage, only for dashboards the user fully trusts.
*/
trusted: boolean;
/** Multi-user owner (username). Undefined in single-user mode. */
owner?: string;
createdAt: number;
lastOpenedAt?: number;
}
/** Result of the server-side probe used by the "Test" button in the editor. */
export interface WebviewProbe {
/** True when the server could complete an HTTP request to the target. */
reachable: boolean;
/** Upstream status code, when a response came back. */
status?: number;
/** Raw `X-Frame-Options` value, if the target sent one. */
xFrameOptions?: string;
/** The `frame-ancestors` directive extracted from the target's CSP, if any. */
frameAncestors?: string;
/** True when the target permits being framed cross-origin by this Codeman. */
framable: boolean;
/** Strategy the UI should default to for this URL. */
recommendedMode: WebviewEmbedMode;
/** Human-readable explanation of the recommendation (or the failure). */
reason: string;
}
/** Payload of `POST /api/webviews/:id/open`. */
export interface WebviewOpenData {
/** The webview being opened (echoed so the client can refresh its copy). */
webview: Webview;
/**
* Same-origin path the iframe should load. Present for `proxy` mode only;
* `direct` mode uses `webview.url` instead.
*/
embedUrl?: string;
/** Epoch ms at which the capability behind `embedUrl` stops working. */
expiresAt?: number;
}
+79 -3
View File
@@ -22,6 +22,8 @@ import {
import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { findUser, setPassword, touchLastLogin, verifyPassword } from '../../user-store.js';
import { webviewCapabilities } from '../../webview-capabilities.js';
import { capabilityFromProxyPath, capabilityFromReferer } from '../webview-proxy.js';
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../../types.js';
// Request-scoped identity (multi-user). Single-user leaves it undefined and the
@@ -120,6 +122,49 @@ function isPasswordChangeExempt(req: FastifyRequest): boolean {
return !url.startsWith('/api/');
}
/**
* Whether this request carries a VALID web-tab proxy capability.
*
* Requests under `/webview/<cap>/` cannot authenticate the normal way. The iframe
* rendering a dashboard is sandboxed without `allow-same-origin`, so it runs in an
* opaque origin: every request it makes is cross-site, meaning the `SameSite=lax`
* `codeman_session` cookie is never attached, and non-GET requests and WebSocket
* upgrades arrive with `Origin: null`. Both the cookie check and the CSRF Origin
* guard would therefore reject a perfectly legitimate dashboard asset load.
*
* The capability in the path is the credential instead: 192 bits of entropy, held
* in memory only (a restart invalidates it), rolling TTL, bound to the user who
* minted it through an already-authenticated `POST /api/webviews/:id/open`, and
* granting nothing but "relay bytes to this one saved URL".
*
* The exemption is deliberately narrow: it requires the capability to RESOLVE, so
* a bare `/webview/anything` reaches nothing, and a `/webviewfoo` path does not
* match the prefix at all. The Host allowlist is NOT bypassed, so DNS-rebinding
* protection still applies to these requests.
*/
function hasValidWebviewCapability(req: FastifyRequest): boolean {
const url = (req.url ?? '').split('?')[0];
const fromPath = capabilityFromProxyPath(url);
if (fromPath) return webviewCapabilities.resolve(fromPath) !== undefined;
// Referer form: a dashboard subresource requested with a ROOT-ABSOLUTE URL, which
// lands on Codeman's root and is relayed by the 404 fallback. Without this the
// asset would be rejected here, before the fallback ever runs.
//
// This is the only exemption decided by a header the request itself supplies, so
// it is fenced in hard: safe methods only, and never for Codeman's own functional
// surfaces. Without those fences a page could present a webview Referer and skip
// auth on /api. It is not a privilege escalation even so, holding a live
// capability already implies an authenticated `POST /api/webviews/:id/open`, but
// the exemption should stay no wider than the problem it solves.
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
if (url.startsWith('/api/') || url.startsWith('/ws/') || url.startsWith('/q/')) return false;
const fromReferer = capabilityFromReferer(typeof req.headers.referer === 'string' ? req.headers.referer : undefined);
return !!fromReferer && webviewCapabilities.resolve(fromReferer) !== undefined;
}
/**
* Register HTTP Basic Auth middleware with session cookies and rate limiting.
* Only active when CODEMAN_PASSWORD is set.
@@ -211,6 +256,12 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
return;
}
// Web-tab proxy, authenticated by the capability in the path, not the cookie.
if (hasValidWebviewCapability(req)) {
done();
return;
}
const clientIp = req.ip;
// Check session cookie first (avoids re-sending credentials on every request)
@@ -341,6 +392,12 @@ function registerMultiUserAuthHook(
// QR redemption path — handled by the route itself.
if (req.url?.startsWith('/q/')) return;
// Web-tab proxy, authenticated by the capability in the path, not the cookie.
// `req.authUser` stays undefined here on purpose: the proxy handler enforces
// ownership against the identity BOUND TO THE CAPABILITY, which is stricter
// than re-deriving it from a request that carries no credentials.
if (hasValidWebviewCapability(req)) return;
const clientIp = req.ip;
// 1. Cookie session (carries identity + mustChangePassword snapshot).
@@ -466,7 +523,17 @@ export function registerHostGuard(app: FastifyInstance, getPolicy: () => HostPol
reply.code(403).send('Forbidden: host not allowed');
return;
}
if (!SAFE_HTTP_METHODS.has(req.method) && !isAllowedRequestOrigin(req.headers.origin, policy)) {
// The Host allowlist above is NEVER bypassed. The Origin (CSRF) check is,
// but only for a request carrying a valid web-tab capability: a sandboxed
// dashboard is opaque-origin, so its form posts and uploads arrive with
// `Origin: null`, which this guard rejects by design. The capability is the
// credential in that case, and it is unguessable, see
// hasValidWebviewCapability.
if (
!SAFE_HTTP_METHODS.has(req.method) &&
!isAllowedRequestOrigin(req.headers.origin, policy) &&
!hasValidWebviewCapability(req)
) {
reply.code(403).send('Forbidden: cross-site request blocked');
return;
}
@@ -521,8 +588,17 @@ export function registerSecurityHeaders(app: FastifyInstance, https: boolean): v
}
}
// Handle CORS preflight
if (req.method === 'OPTIONS') {
// Handle CORS preflight.
//
// EXCEPT for the web-tab proxy, which must answer its own preflight. A
// sandboxed dashboard iframe is opaque-origin, so it sends `Origin: null`;
// the CORS block above only emits headers for localhost origins, so a bare
// 204 from here carries no `Access-Control-Allow-Origin` and the browser
// rejects the preflight. Every dashboard fetch then fails with an opaque
// net::ERR_FAILED while the page itself renders fine (script/css/img loads
// are not CORS-checked). Falling through lets the proxy route reply with the
// right headers.
if (req.method === 'OPTIONS' && !hasValidWebviewCapability(req)) {
reply.code(204).send();
done();
return;
+44 -4
View File
@@ -294,6 +294,9 @@ const _SSE_HANDLER_MAP = [
// Session order (global tab order sync, COD-131)
[SSE_EVENTS.SESSION_ORDER_CHANGED, '_onSessionOrderChanged'],
// Web tabs (dashboard URLs)
[SSE_EVENTS.WEBVIEW_CHANGED, '_onWebviewChanged'],
];
@@ -821,6 +824,7 @@ class CodemanApp {
const settingsPromise = fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.data ?? null).catch(() => null);
this.loadQuickStartCases(null, settingsPromise);
this._initRunMode();
this.initWebviews?.();
this.setupEventListeners();
// Mobile: ensure button taps register even when keyboard is visible.
// On mobile, tapping a button while the soft keyboard is up causes the
@@ -1007,9 +1011,18 @@ class CodemanApp {
const digitMatch = code.match(/^Digit([1-9])$/);
if (digitMatch) {
const idx = parseInt(digitMatch[1], 10) - 1;
// Sessions occupy 1..N and web tabs continue from N+1, matching the
// numbers actually painted on the tabs.
if (idx < this.sessionOrder.length) {
e.preventDefault();
this.selectSession(this.sessionOrder[idx]);
} else {
const webIdx = idx - this.sessionOrder.length;
const webId = (this.webviewOrder || [])[webIdx];
if (webId) {
e.preventDefault();
this.openWebview(webId);
}
}
return;
}
@@ -3272,9 +3285,21 @@ class CodemanApp {
const existingIds = new Set([...existingTabs].map(t => t.dataset.id));
const currentIds = new Set(this.sessions.keys());
// Check if we can do incremental update (same session IDs)
// Web tabs live in the same strip but are not in this.sessions, so they need
// their own change check. Without it, the session-only comparison below is
// vacuously "unchanged" whenever session count is stable — most visibly with
// ZERO sessions (0 === 0), where opening a dashboard would never draw its tab.
const existingWebIds = [...container.querySelectorAll('.session-tab[data-webview-id]')].map(
t => t.dataset.webviewId
);
const wantedWebIds = (this.webviewOrder || []).filter(id => this.webviews?.has(id));
const webTabsUnchanged =
existingWebIds.length === wantedWebIds.length && existingWebIds.every((id, i) => id === wantedWebIds[i]);
// Check if we can do incremental update (same session IDs and same web tabs)
const canIncremental = existingIds.size === currentIds.size &&
[...existingIds].every(id => currentIds.has(id));
[...existingIds].every(id => currentIds.has(id)) &&
webTabsUnchanged;
if (canIncremental) {
// Incremental update - only modify changed properties
@@ -3282,7 +3307,12 @@ class CodemanApp {
const tab = container.querySelector(`.session-tab[data-id="${id}"]`);
if (!tab) continue;
const isActive = id === this.activeSessionId;
// A web tab owns the active state while one is open. activeSessionId stays
// set (the terminal keeps streaming underneath, and switching back is
// instant): only the highlight moves. Without this the debounced render
// re-marks the session tab active moments after a web tab was selected,
// leaving two tabs lit at once.
const isActive = id === this.activeSessionId && !this.activeWebviewId;
const status = session.status || 'idle';
const name = this.getSessionName(session);
const taskStats = session.taskStats || { running: 0, total: 0 };
@@ -3469,7 +3499,9 @@ class CodemanApp {
const session = this.sessions.get(id);
if (!session) continue; // Skip if session was removed
const isActive = id === this.activeSessionId;
// See the note in the incremental path: a web tab owns the active highlight
// while one is open, even though activeSessionId stays set.
const isActive = id === this.activeSessionId && !this.activeWebviewId;
const status = session.status || 'idle';
const name = this.getSessionName(session);
const mode = session.mode || 'claude';
@@ -3516,6 +3548,11 @@ class CodemanApp {
_tabIdx++;
}
// Web tabs (dashboard URLs) render after the session tabs, continuing the
// Alt+N numbering. They carry data-webview-id instead of data-id, so every
// session-tab code path above (drag-and-drop, alerts, badges) skips them.
parts.push(this.renderWebviewTabs ? this.renderWebviewTabs(_tabIdx) : '');
container.innerHTML = parts.join('');
// Set up drag-and-drop handlers for tab reordering
@@ -4050,6 +4087,9 @@ class CodemanApp {
return; // newer tab switch won
}
// A session tab takes the stage back from any active web tab.
this._hideWebviewLayer?.();
this._cleanupPreviousSession(sessionId);
this.activeSessionId = sessionId;
try { localStorage.setItem('codeman-active-session', sessionId); } catch {}
+3
View File
@@ -494,6 +494,9 @@ const SSE_EVENTS = {
// Session order (global tab order sync)
SESSION_ORDER_CHANGED: 'session:orderChanged',
// Web tabs (dashboard URLs)
WEBVIEW_CHANGED: 'webview:changed',
};
// ═══════════════════════════════════════════════════════════════
+97 -12
View File
@@ -45,20 +45,20 @@
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
<!-- Synchronous skin selection — runs before first paint to prevent theme flash -->
<script>try{var s=localStorage.getItem('codeman:skin');if(s!=='og'&&s!=='daylight-green'&&s!=='daylight-blue')s='daylight-blue';document.documentElement.dataset.skin=s;window.__codemanSkin=s;}catch(e){document.documentElement.dataset.skin='daylight-blue';window.__codemanSkin='daylight-blue';}</script>
<script>try{var s=localStorage.getItem('codeman:skin'),a=['og','daylight-green','daylight-blue','paper-gray','solarized-light','catppuccin-latte','rose-pine-dawn'];if(a.indexOf(s)<0)s='daylight-blue';document.documentElement.dataset.skin=s;window.__codemanSkin=s;}catch(e){document.documentElement.dataset.skin='daylight-blue';window.__codemanSkin='daylight-blue';}</script>
<!-- Apply the saved per-device language before first paint. The full translation
layer loads below; setting lang/dir here prevents an English accessibility
tree from flashing while the deferred scripts start. -->
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var l=JSON.parse(localStorage.getItem(k)||'{}').language;l=l==='zh-CN'?'zh-CN':'en';document.documentElement.lang=l;window.__codemanLanguage=l;}catch(e){document.documentElement.lang='en';window.__codemanLanguage='en';}</script>
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
<style>
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#11151c}
.skeleton-header{height:40px;background:rgba(31,38,48,0.85);border-bottom:1px solid rgba(255,255,255,0.08);display:flex;align-items:center;padding:0 12px}
.skeleton-brand{color:#38b6f0;font-size:14px;font-weight:700;font-family:'Manrope',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.85}
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
.skeleton-header{height:40px;background:var(--glass-bg,rgba(31,38,48,0.85));border-bottom:1px solid var(--glass-border,rgba(255,255,255,0.08));display:flex;align-items:center;padding:0 12px}
.skeleton-brand{color:var(--accent,#38b6f0);font-size:14px;font-weight:700;font-family:'Manrope',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.85}
.skeleton-tabs{display:flex;gap:4px;margin-left:16px}
.skeleton-tab{width:80px;height:24px;background:rgba(255,255,255,0.04);border-radius:6px}
.skeleton-terminal{flex:1;background:#161b23}
.skeleton-toolbar{height:42px;background:rgba(31,38,48,0.85);border-top:1px solid rgba(255,255,255,0.08)}
.skeleton-tab{width:80px;height:24px;background:var(--control-bg,rgba(255,255,255,0.04));border-radius:6px}
.skeleton-terminal{flex:1;background:var(--term-bg,#161b23)}
.skeleton-toolbar{height:42px;background:var(--glass-bg,rgba(31,38,48,0.85));border-top:1px solid var(--glass-border,rgba(255,255,255,0.08))}
.app-loaded .loading-skeleton{display:none}
</style>
</head>
@@ -300,6 +300,11 @@
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
</div>
<!-- Web tab layer: one iframe per open dashboard, shown in place of the
terminal while a web tab is active. Frames stay mounted while hidden so
switching tabs does not reload (and re-authenticate) a dashboard. -->
<div class="webview-layer" id="webviewLayer"></div>
<!-- Welcome Overlay (shown when no session active) -->
<div class="welcome-overlay" id="welcomeOverlay">
<div class="welcome-content">
@@ -460,6 +465,18 @@
<span class="run-mode-dot gemini"></span>Gemini
</button>
<div class="run-mode-sep"></div>
<button class="run-mode-option" data-mode="shell" onclick="app.setRunMode('shell')">
<span class="run-mode-dot shell"></span>Terminal / Shell
</button>
<div class="run-mode-sep"></div>
<!-- Web tabs: dashboards open as tabs beside agent sessions. These do NOT
set runMode: the Run button always means "start an agent". -->
<div class="run-mode-header">Web / URL</div>
<div class="run-mode-webviews" id="runModeWebviews"></div>
<button class="run-mode-option run-mode-option--add" onclick="app.showWebviewModal()">
<span class="run-mode-dot web"></span>Add URL&hellip;
</button>
<div class="run-mode-sep"></div>
<div class="run-mode-header">Recent Sessions</div>
<div class="run-mode-history" id="runModeHistory"></div>
</div>
@@ -475,6 +492,12 @@
<button class="btn-toolbar btn-shell" onclick="app.runShell()" title="Run Shell">
Run Shell
</button>
<!-- Phone-only: replaces the Shell button on ≤430px (Shell moves into the Run
dropdown there). Sends a bare Enter to the active session, the complement
to the accessory bar's Esc. Hidden everywhere else — see styles.css. -->
<button class="btn-toolbar btn-enter" onclick="app.sendEnterKey()" title="Send Enter">
Enter
</button>
<div class="tab-count-group" title="Instance count">
<button class="tab-count-btn" onclick="app.decrementShellCount()">−</button>
<input type="number" id="shellCount" class="tab-count-input" value="1" min="1" max="20" readonly>
@@ -629,6 +652,56 @@
</div>
</div>
<!-- Web Tab (dashboard URL) editor -->
<div class="modal" id="webviewModal">
<div class="modal-backdrop" onclick="app.closeWebviewModal()"></div>
<div class="modal-content">
<div class="modal-header">
<h3 id="webviewModalTitle">Add URL</h3>
<button class="modal-close" onclick="app.closeWebviewModal()" aria-label="Close URL editor">&times;</button>
</div>
<div class="modal-body">
<div class="form-row">
<label for="webviewName">Name</label>
<input type="text" id="webviewName" placeholder="Grafana" autocomplete="off" spellcheck="false">
</div>
<div class="form-row">
<label for="webviewUrl">URL</label>
<input type="text" id="webviewUrl" placeholder="http://100.70.56.18:4000/" autocomplete="off"
autocapitalize="off" spellcheck="false">
<span class="form-hint">
Reached from the Codeman server, so a tailnet or localhost address works even when
this browser cannot see it. Plain HTTP is fine: the dashboard is proxied through
Codeman, which is also what gets past dashboards that refuse to be embedded.
</span>
</div>
<div class="form-row">
<label for="webviewIcon">Icon</label>
<!-- Click to pick; the field stays editable so any emoji still works. -->
<div class="webview-icon-picker" id="webviewIconPicker" role="group" aria-label="Choose an icon"></div>
<input type="text" id="webviewIcon" placeholder="Or paste any emoji" maxlength="8" autocomplete="off">
</div>
<div class="form-row">
<label class="checkbox-row"><input type="checkbox" id="webviewSandboxed" checked> Open sandboxed</label>
<span class="form-hint">
Recommended. A proxied dashboard is served from Codeman's own address, so unchecking
this lets its JavaScript read this page and call the API that starts agents. Uncheck
only for a dashboard you fully trust, or one whose own login needs cookies.
</span>
</div>
<div class="form-row">
<button class="btn-secondary" onclick="app.testWebviewUrl()">Test</button>
<span class="form-hint webview-probe-result" id="webviewProbeResult"></span>
</div>
</div>
<div class="form-actions webview-modal-actions">
<button class="btn-danger" id="webviewDeleteBtn" onclick="app.deleteWebview()">Delete</button>
<button class="btn-secondary" onclick="app.closeWebviewModal()">Cancel</button>
<button class="btn-primary" onclick="app.saveWebview()">Save</button>
</div>
</div>
</div>
<!-- Cron Jobs Modal -->
<div class="modal" id="cronModal">
<div class="modal-backdrop" onclick="app.closeCron()"></div>
@@ -1190,9 +1263,17 @@
<div class="settings-item settings-item-skin" title="Visual theme for this device (not synced)">
<span class="settings-item-label">Skin</span>
<select id="appSettingsSkin" class="form-select">
<option value="daylight-blue">Daylight Blue</option>
<option value="daylight-green">Daylight Green</option>
<option value="og">OG Codeman</option>
<optgroup label="Light">
<option value="paper-gray">Paper Gray</option>
<option value="solarized-light">Solarized Light</option>
<option value="catppuccin-latte">Catppuccin Latte</option>
<option value="rose-pine-dawn">Rosé Pine Dawn</option>
</optgroup>
<optgroup label="Dark">
<option value="daylight-blue">Daylight Blue</option>
<option value="daylight-green">Daylight Green</option>
<option value="og">OG Codeman</option>
</optgroup>
</select>
</div>
<div class="settings-item" id="appSettingsWebglRendererItem" title="Use the GPU-accelerated WebGL terminal renderer (desktop only). Turn off to force the DOM renderer if you hit GPU glitches. Codeman also auto-falls-back to the DOM renderer after repeated GPU stalls.">
@@ -1962,8 +2043,11 @@
</div>
<div class="form-row">
<label>Folder Path</label>
<input type="text" id="linkCasePath" placeholder="/home/user/projects/my-project" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
<span class="form-hint">Absolute path to an existing project folder, e.g. /home/you/my-project</span>
<div class="path-input-group">
<input type="text" id="linkCasePath" placeholder="/mnt/d/AI/my-project" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
<button type="button" class="btn path-input-browse" onclick="app.openLinkCasePathPicker()">Browse&hellip;</button>
</div>
<span class="form-hint">Choose an existing folder from this computer or enter its absolute path</span>
</div>
</div>
<!-- Remote Tab -->
@@ -2541,6 +2625,7 @@
<script defer src="ultracode-panel.js"></script>
<script defer src="admin-ui.js"></script>
<script defer src="session-ui.js"></script>
<script defer src="webview-tabs.js"></script>
<script defer src="ralph-wizard.js"></script>
<script defer src="api-client.js"></script>
<script defer src="subagent-windows.js"></script>
+361 -2
View File
@@ -1,7 +1,7 @@
/**
* @fileoverview Mobile keyboard accessory bar and modal focus trap.
*
* Defines two exports:
* Defines three exports:
*
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
* keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, Esc, and dismiss.
@@ -10,12 +10,15 @@
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
* Commands are sent as text + Enter separately for Ink compatibility.
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
* - PathPicker (singleton object) — Lazy server-side file/folder browser shared
* by Link Existing and the extended mobile keyboard bar.
*
* - FocusTrap (class) — Traps Tab/Shift+Tab keyboard focus within a modal element.
* Saves and restores previously focused element on deactivate. Used by Ralph wizard
* and other modal dialogs.
*
* @globals {object} KeyboardAccessoryBar
* @globals {object} PathPicker
* @globals {class} FocusTrap
*
* @dependency mobile-handlers.js (MobileDetection.isTouchDevice)
@@ -26,6 +29,338 @@
// Codeman — Keyboard accessory bar and focus trap for modals
// Loaded after mobile-handlers.js, before app.js
// ═══════════════════════════════════════════════════════════════
// Shared Filesystem Path Picker
// ═══════════════════════════════════════════════════════════════
const PathPicker = {
overlay: null,
_options: null,
_selectedPath: '',
_previousFocus: null,
_keydownHandler: null,
_loadSequence: 0,
_previewOverlay: null,
_previewRequestSequence: 0,
_previewPreviousFocus: null,
/**
* Open the lazy filesystem browser.
* @param {{sessionId?: string, initialPath?: string, directoriesOnly?: boolean,
* title?: string, onSelect: (path: string) => void}} options
*/
open(options) {
this.close(false);
this._options = options;
this._selectedPath = '';
this._previousFocus = document.activeElement;
this._previousFocus?.blur?.();
const overlay = document.createElement('div');
overlay.className = 'path-picker-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', options.title || 'Select a path');
overlay.innerHTML = `
<div class="path-picker-dialog">
<div class="path-picker-header">
<strong class="path-picker-title"></strong>
<button type="button" class="path-picker-close" aria-label="Close">&times;</button>
</div>
<div class="path-picker-roots-row">
<label for="pathPickerRoot">Location</label>
<select id="pathPickerRoot" class="path-picker-roots"></select>
</div>
<div class="path-picker-nav">
<button type="button" class="path-picker-up" title="Parent folder" aria-label="Parent folder">&#x2191;</button>
<div class="path-picker-current" title="Current folder"></div>
<button type="button" class="path-picker-refresh" title="Refresh" aria-label="Refresh">&#x21BB;</button>
</div>
<div class="path-picker-status" aria-live="polite">Loading...</div>
<div class="path-picker-list" role="listbox"></div>
<div class="path-picker-selection">
<span class="path-picker-selection-label">Selected</span>
<span class="path-picker-selection-value">None</span>
</div>
<div class="path-picker-actions">
<button type="button" class="path-picker-current-select">Select Current Folder</button>
<span class="path-picker-action-spacer"></span>
<button type="button" class="path-picker-cancel">Cancel</button>
<button type="button" class="path-picker-confirm" disabled>Select</button>
</div>
</div>`;
this.overlay = overlay;
overlay.querySelector('.path-picker-title').textContent = options.title || 'Select a Path';
overlay.querySelector('.path-picker-close').addEventListener('click', () => this.close(true));
overlay.querySelector('.path-picker-cancel').addEventListener('click', () => this.close(true));
overlay.querySelector('.path-picker-confirm').addEventListener('click', () => this.confirm());
overlay.querySelector('.path-picker-current-select').addEventListener('click', () => {
const current = overlay.querySelector('.path-picker-current').textContent;
if (current) this.select(current);
});
overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load());
overlay.querySelector('.path-picker-up').addEventListener('click', () => {
const parent = overlay.querySelector('.path-picker-up').dataset.parent;
if (parent) this.load(parent);
});
overlay.querySelector('.path-picker-roots').addEventListener('change', (event) => this.load(event.target.value));
overlay.addEventListener('click', (event) => {
if (event.target === overlay) this.close(true);
});
this._keydownHandler = (event) => {
if (event.key === 'Escape') {
event.preventDefault();
if (this._previewOverlay) this.closePreview(true);
else this.close(true);
}
};
document.addEventListener('keydown', this._keydownHandler);
document.body.appendChild(overlay);
this.load(options.initialPath || '');
},
async load(path) {
if (!this.overlay || !this._options) return;
const loadSequence = ++this._loadSequence;
const list = this.overlay.querySelector('.path-picker-list');
const status = this.overlay.querySelector('.path-picker-status');
list.replaceChildren();
status.textContent = 'Loading...';
const params = new URLSearchParams();
if (path) params.set('path', path);
if (this._options.sessionId) params.set('sessionId', this._options.sessionId);
try {
const response = await fetch(`/api/filesystem/browse?${params.toString()}`);
const result = await response.json();
if (!response.ok || !result.success) throw new Error(result.error || 'Failed to browse this folder');
if (!this.overlay || loadSequence !== this._loadSequence) return;
this.render(result.data);
} catch (error) {
if (!this.overlay || loadSequence !== this._loadSequence) return;
if (path) {
this.load('');
return;
}
status.textContent = error.message || 'Failed to browse this folder';
status.classList.add('error');
}
},
render(data) {
const rootSelect = this.overlay.querySelector('.path-picker-roots');
rootSelect.replaceChildren();
for (const root of data.roots) {
const option = document.createElement('option');
option.value = root.path;
option.textContent = `${root.label} — ${root.path}`;
option.selected = data.path === root.path || data.root === root.path;
rootSelect.appendChild(option);
}
this.overlay.querySelector('.path-picker-current').textContent = data.path;
const up = this.overlay.querySelector('.path-picker-up');
up.dataset.parent = data.parent || '';
up.disabled = !data.parent;
const status = this.overlay.querySelector('.path-picker-status');
status.classList.remove('error');
status.textContent = data.entries.length === 0
? 'This folder is empty'
: `${data.entries.length} item${data.entries.length === 1 ? '' : 's'}${data.truncated ? ' (first 500)' : ''}`;
const list = this.overlay.querySelector('.path-picker-list');
list.replaceChildren();
for (const entry of data.entries) {
const row = document.createElement('div');
row.className = 'path-picker-item';
if (entry.type === 'file' && this._options.directoriesOnly && !entry.previewKind) {
row.classList.add('not-selectable');
}
row.dataset.path = entry.path;
row.dataset.type = entry.type;
row.setAttribute('role', 'option');
const open = document.createElement('button');
open.type = 'button';
open.className = 'path-picker-item-main';
const icon = document.createElement('span');
icon.className = 'path-picker-item-icon';
icon.textContent = entry.type === 'directory' ? '\uD83D\uDCC1' : '\uD83D\uDCC4';
const name = document.createElement('span');
name.className = 'path-picker-item-name';
name.textContent = entry.name;
open.append(icon, name);
if (entry.symlink) {
const link = document.createElement('span');
link.className = 'path-picker-item-link';
link.textContent = '\u2197';
open.appendChild(link);
}
if (entry.type === 'directory') {
const chevron = document.createElement('span');
chevron.className = 'path-picker-item-chevron';
chevron.textContent = '\u203A';
open.appendChild(chevron);
open.addEventListener('click', () => this.load(entry.path));
} else if (entry.previewKind) {
const preview = document.createElement('span');
preview.className = 'path-picker-item-preview';
preview.textContent = '\uD83D\uDC41';
open.appendChild(preview);
open.title = `Preview ${entry.name}`;
open.setAttribute('aria-label', `Preview ${entry.name}`);
open.addEventListener('click', () => this.openPreview(entry));
} else if (!this._options.directoriesOnly) {
open.addEventListener('click', () => this.select(entry.path));
} else {
open.disabled = true;
}
row.appendChild(open);
if (entry.type === 'directory' || !this._options.directoriesOnly) {
const choose = document.createElement('button');
choose.type = 'button';
choose.className = 'path-picker-item-select';
choose.textContent = 'Choose';
choose.addEventListener('click', () => this.select(entry.path));
row.appendChild(choose);
}
list.appendChild(row);
}
},
select(path) {
if (!this.overlay) return;
this._selectedPath = path;
this.overlay.querySelector('.path-picker-selection-value').textContent = path;
this.overlay.querySelector('.path-picker-confirm').disabled = false;
this.overlay.querySelectorAll('.path-picker-item').forEach((row) => {
const selected = row.dataset.path === path;
row.classList.toggle('selected', selected);
row.setAttribute('aria-selected', selected ? 'true' : 'false');
});
},
openPreview(entry) {
this.closePreview(false);
this._previewPreviousFocus = document.activeElement;
const requestSequence = ++this._previewRequestSequence;
const params = new URLSearchParams({ path: entry.path });
if (this._options?.sessionId) params.set('sessionId', this._options.sessionId);
const previewUrl = `/api/filesystem/preview?${params.toString()}`;
const overlay = document.createElement('div');
overlay.className = 'path-preview-overlay';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', `Preview ${entry.name}`);
overlay.innerHTML = `
<div class="path-preview-dialog">
<div class="path-preview-header">
<div class="path-preview-heading">
<strong class="path-preview-title"></strong>
<span class="path-preview-path"></span>
</div>
<a class="path-preview-open" target="_blank" rel="noopener noreferrer">Open</a>
<button type="button" class="path-preview-close" aria-label="Close preview">&times;</button>
</div>
<div class="path-preview-body"><div class="path-preview-loading">Loading preview...</div></div>
</div>`;
overlay.querySelector('.path-preview-title').textContent = entry.name;
overlay.querySelector('.path-preview-path').textContent = entry.path;
overlay.querySelector('.path-preview-open').href = previewUrl;
overlay.querySelector('.path-preview-close').addEventListener('click', () => this.closePreview(true));
overlay.addEventListener('click', (event) => {
if (event.target === overlay) this.closePreview(true);
});
document.body.appendChild(overlay);
this._previewOverlay = overlay;
const body = overlay.querySelector('.path-preview-body');
if (entry.previewKind === 'image') {
const image = document.createElement('img');
image.className = 'path-preview-image';
image.alt = entry.name;
image.addEventListener('load', () => body.querySelector('.path-preview-loading')?.remove());
image.addEventListener('error', () => this.showPreviewError('Image preview failed to load'));
image.src = previewUrl;
body.appendChild(image);
} else if (entry.previewKind === 'text') {
fetch(previewUrl)
.then(async (response) => {
const content = await response.text();
if (!response.ok) {
let message = 'Text preview failed to load';
try {
message = JSON.parse(content).error || message;
} catch {}
throw new Error(message);
}
return content;
})
.then((content) => {
if (!this._previewOverlay || requestSequence !== this._previewRequestSequence) return;
const pre = document.createElement('pre');
pre.className = 'path-preview-text';
pre.textContent = content;
body.replaceChildren(pre);
})
.catch((error) => {
if (requestSequence === this._previewRequestSequence) this.showPreviewError(error.message);
});
} else {
const frame = document.createElement('iframe');
frame.className = 'path-preview-frame';
frame.title = entry.name;
frame.addEventListener('load', () => body.querySelector('.path-preview-loading')?.remove());
frame.src = previewUrl;
body.appendChild(frame);
}
overlay.querySelector('.path-preview-close').focus();
},
showPreviewError(message) {
const body = this._previewOverlay?.querySelector('.path-preview-body');
if (!body) return;
const error = document.createElement('div');
error.className = 'path-preview-error';
error.textContent = message || 'Preview failed to load';
body.replaceChildren(error);
},
closePreview(restoreFocus = true) {
this._previewRequestSequence += 1;
this._previewOverlay?.remove();
this._previewOverlay = null;
const previousFocus = this._previewPreviousFocus;
this._previewPreviousFocus = null;
if (restoreFocus) previousFocus?.focus?.();
},
confirm() {
if (!this._selectedPath || !this._options) return;
const selectedPath = this._selectedPath;
const onSelect = this._options.onSelect;
this.close(false);
onSelect(selectedPath);
},
close(restoreFocus = true) {
if (this._keydownHandler) document.removeEventListener('keydown', this._keydownHandler);
this._keydownHandler = null;
this._loadSequence += 1;
this.closePreview(false);
this.overlay?.remove();
this.overlay = null;
const previousFocus = this._previousFocus;
this._previousFocus = null;
this._options = null;
this._selectedPath = '';
if (restoreFocus) previousFocus?.focus?.();
},
};
// ═══════════════════════════════════════════════════════════════
// Mobile Keyboard Accessory Bar
// ═══════════════════════════════════════════════════════════════
@@ -92,6 +427,8 @@ const KeyboardAccessoryBar = {
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
</svg>
</button>
<button class="accessory-btn" data-action="pick-path" title="Insert a file or folder path">&#x1F4C1; Path</button>
<button class="accessory-btn" data-action="clear-input" title="Clear the current unsent input">&#x232B; All</button>
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
<button class="accessory-btn" data-action="shift-tab" title="Shift+Tab">⇧Tab</button>
<button class="accessory-btn" data-action="effort-max" title="/effort max">Max</button>
@@ -128,7 +465,7 @@ const KeyboardAccessoryBar = {
this.handleAction(action, btn);
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max']);
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max', 'clear-input']);
if (refocusActions.has(action) ||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
if (typeof app !== 'undefined' && app.terminal) {
@@ -207,6 +544,12 @@ const KeyboardAccessoryBar = {
case 'paste':
this.pasteFromClipboard();
break;
case 'pick-path':
this.pickPath();
break;
case 'clear-input':
app.clearTerminalInput?.();
break;
case 'dismiss':
// Blur active element to dismiss keyboard
document.activeElement?.blur();
@@ -265,6 +608,22 @@ const KeyboardAccessoryBar = {
}).catch(() => {});
},
/** Browse the active session's workspace and insert a selected path without Enter. */
pickPath() {
if (!app.activeSessionId) return;
const session = app.sessions?.get(app.activeSessionId);
PathPicker.open({
title: 'Insert File or Folder Path',
sessionId: app.activeSessionId,
initialPath: session?.workingDir || '',
directoriesOnly: false,
onSelect: (path) => {
app.insertTerminalText?.(path);
setTimeout(() => app.terminal?.focus(), 100);
},
});
},
/** Show a paste overlay for iOS compatibility.
* Handles three input paths from one dialog:
* - Text: long-press the textarea → Paste → Send (unchanged).
+98 -26
View File
@@ -875,19 +875,44 @@ html.mobile-init .file-browser-panel {
margin-right: 0;
}
/* Secondary action - Run Shell - right side */
/* Shell is NOT a toolbar button on phones — it moved into the Run dropdown
(Terminal / Shell), freeing this slot for Enter. Starting a shell is a rare,
deliberate act; sending Enter is a constant one, so the scarce phone real
estate goes to Enter. */
.btn-toolbar.btn-shell {
flex: 0 0 auto;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
color: #9ca3af;
order: 4; /* Right position */
display: none !important;
}
.btn-toolbar.btn-shell:hover,
.btn-toolbar.btn-shell:active {
background: rgba(255, 255, 255, 0.1);
color: #fff;
/* Secondary action - Enter - right side. Takes the slot (and the order) the
Shell button used to hold, so the toolbar rhythm is unchanged. */
.btn-toolbar.btn-enter {
display: flex !important;
flex: 0 0 auto;
align-items: center;
justify-content: center;
min-width: 54px;
width: 54px;
white-space: nowrap;
padding: 0 8px !important;
overflow: hidden;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.01em;
/* !important is REQUIRED here, not defensive habit: styles.css nests its skin
overrides inside `html:not([data-skin="og"]) { … }`, so a plain `.btn-toolbar`
in that block resolves to (0,2,1) and outranks this (0,2,0) rule. Without
!important the button silently renders in generic toolbar grey. */
background: rgba(30, 58, 95, 0.85) !important;
border: 1px solid rgba(59, 130, 246, 0.45) !important;
color: #dbeafe !important;
order: 4; /* Right position — same slot Shell used to occupy */
}
.btn-toolbar.btn-enter:hover,
.btn-toolbar.btn-enter:active {
background: rgba(37, 74, 122, 0.95) !important;
border-color: rgba(59, 130, 246, 0.7) !important;
color: #fff !important;
}
/* Hide case selector on mobile - simplified toolbar */
@@ -895,27 +920,12 @@ html.mobile-init .file-browser-panel {
display: none !important;
}
/* Simplified toolbar layout — Run, Shell, and Case */
/* Simplified toolbar layout — Run, Enter, and Case */
.toolbar-left .toolbar-group:first-child {
width: 100%;
gap: 8px;
}
.btn-toolbar.btn-shell {
flex: 0 0 auto;
min-width: 54px;
width: 54px;
white-space: nowrap;
padding: 0 8px !important;
overflow: hidden;
font-size: 0 !important;
}
.btn-toolbar.btn-shell::after {
content: "Shell";
font-size: 0.65rem;
}
/* Mobile case button - visible on mobile */
.btn-toolbar.btn-case-mobile {
display: flex !important;
@@ -2226,6 +2236,68 @@ html.mobile-init .file-browser-panel {
}
}
/* Light-skin compatibility for mobile-only chrome. These components predate
the shared skin system and intentionally retain their original dark values
for the three dark skins above. */
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.header, .toolbar, .keyboard-accessory-bar) {
background: var(--glass-bg);
border-color: var(--glass-border);
color: var(--text);
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .toolbar .btn-case-add, .accessory-btn) {
background: var(--control-bg);
border-color: var(--control-border);
color: var(--text-dim);
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile:active, .btn-settings-mobile:active, .btn-toolbar.btn-shell:hover, .btn-toolbar.btn-shell:active, .btn-case-add:hover, .btn-case-add:active, .accessory-btn:active) {
background: var(--control-bg-hover);
border-color: var(--control-border-hover);
color: var(--text);
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-claude, .btn-toolbar.btn-run-gear.mode-claude) {
background: linear-gradient(135deg, var(--accent-grad-a), var(--accent-grad-b));
border-color: var(--accent);
color: var(--accent-ink);
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-opencode, .btn-toolbar.btn-run-gear.mode-opencode) {
background: linear-gradient(135deg, var(--accent-d), var(--accent-grad-b));
border-color: var(--accent);
color: var(--accent-ink);
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-gemini, .btn-toolbar.btn-run-gear.mode-gemini) {
background: linear-gradient(135deg, #174ea6, #4f46e5);
border-color: #315fc3;
color: #ffffff;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-toolbar.btn-run-gear {
border-left-color: var(--control-border-hover) !important;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.case-settings-popover-mobile, .mobile-case-picker-sheet) {
background: var(--floating-bg);
border-color: var(--control-border);
color: var(--text);
box-shadow: var(--elevated-shadow);
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.case-settings-popover-mobile .checkbox-inline, #createCaseModal .form-row label) {
color: var(--text);
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .case-settings-popover-mobile .form-hint {
color: var(--text-muted);
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .mobile-case-picker .modal-backdrop {
background: var(--modal-backdrop);
}
/* Keyboard accessory bar + paste overlay base styles moved to styles.css
(always loaded — covers iPad landscape where mobile.css doesn't load).
+1
View File
@@ -2267,6 +2267,7 @@ Object.assign(CodemanApp.prototype, {
const terminal = new Terminal({
theme: { ...window.codemanCurrentXtermTheme() },
minimumContrastRatio: window.codemanCurrentSkinIsLight() ? 4.5 : 1,
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
fontSize: 12,
lineHeight: 1.2,
+45 -1
View File
@@ -394,6 +394,9 @@ Object.assign(CodemanApp.prototype, {
if (mode === 'gemini') {
return await this.runGemini();
}
if (mode === 'shell') {
return await this.runShell();
}
return await this.runClaude();
} finally {
const remaining = minLockMs - (Date.now() - startedAt);
@@ -500,10 +503,32 @@ Object.assign(CodemanApp.prototype, {
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
}
if (label) {
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : 'Run';
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'shell' ? 'Run SH' : 'Run';
}
},
/** Send Enter to the active session (phone toolbar button).
*
* MUST go through xterm's onData path, NOT straight to sendInput()/the API.
* With local echo on (the mobile default) the characters you typed are still
* buffered in the LocalEchoOverlay and have NEVER reached the PTY. The onData
* Enter branch (terminal-ui.js) is what flushes that pending text and only
* then sends \r. Send a bare \r instead and you submit an empty line while the
* typed text stays stranded on screen — which reads as "the button does
* nothing". triggerDataEvent replays it exactly as if the key were pressed,
* so overlay flush, flushed-offset cleanup and ordering are all reused. */
sendEnterKey() {
if (!this.activeSessionId) return;
const coreService = this.terminal?._core?.coreService;
if (coreService && typeof coreService.triggerDataEvent === 'function') {
coreService.triggerDataEvent('\r', true);
return;
}
// Fallback only if xterm's private core API moves: correct when local echo
// is off, and still better than doing nothing.
this.sendInput('\r');
},
_initRunMode() {
try { this._runMode = localStorage.getItem('codeman_runMode') || 'claude'; } catch { this._runMode = 'claude'; }
this._applyRunMode();
@@ -1863,6 +1888,25 @@ Object.assign(CodemanApp.prototype, {
}
},
openLinkCasePathPicker() {
const pathInput = document.getElementById('linkCasePath');
PathPicker.open({
title: 'Select Existing Project Folder',
initialPath: pathInput.value.trim(),
directoriesOnly: true,
onSelect: (path) => {
pathInput.value = path;
const nameInput = document.getElementById('linkCaseName');
if (!nameInput.value.trim()) {
const folderName = path.split('/').filter(Boolean).pop() || '';
if (/^[\p{L}\p{N}_-]+$/u.test(folderName)) nameInput.value = folderName;
}
pathInput.focus();
pathInput.setSelectionRange(path.length, path.length);
},
});
},
async linkRemoteCase() {
const name = document.getElementById('remoteCaseName').value.trim();
const remotePath = document.getElementById('remoteCasePath').value.trim();
+2
View File
@@ -1866,6 +1866,8 @@ Object.assign(CodemanApp.prototype, {
const skin = settings.skin ?? defaults.skin ?? 'daylight-blue';
document.documentElement.setAttribute('data-skin', skin);
window.__codemanSkin = skin;
const themeColor = getComputedStyle(document.documentElement).getPropertyValue('--bg-dark').trim();
if (themeColor) document.querySelector('meta[name="theme-color"]')?.setAttribute('content', themeColor);
try {
localStorage.setItem('codeman:skin', skin);
} catch (_e) {
+943 -176
View File
File diff suppressed because it is too large Load Diff
+156 -17
View File
@@ -40,11 +40,22 @@
og: { background: '#0d0d0d', foreground: '#e0e0e0', cursor: '#e0e0e0', cursorAccent: '#0d0d0d', selection: 'rgba(255,255,255,0.3)', black: '#0d0d0d', red: '#ff6b6b', green: '#51cf66', yellow: '#ffd43b', blue: '#339af0', magenta: '#cc5de8', cyan: '#22b8cf', white: '#e0e0e0', brightBlack: '#495057', brightRed: '#ff8787', brightGreen: '#69db7c', brightYellow: '#ffe066', brightBlue: '#5c7cfa', brightMagenta: '#da77f2', brightCyan: '#66d9e8', brightWhite: '#ffffff' },
'daylight-green': { background: '#161b23', foreground: '#dfe6ef', cursor: '#2fd3aa', cursorAccent: '#161b23', selection: 'rgba(47,211,170,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' },
'daylight-blue': { background: '#161b23', foreground: '#dfe6ef', cursor: '#38b6f0', cursorAccent: '#161b23', selection: 'rgba(56,182,240,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' },
'paper-gray': { background: '#f6f8fa', foreground: '#1f2328', cursor: '#0969da', cursorAccent: '#ffffff', selection: 'rgba(9,105,218,0.2)', black: '#24292f', red: '#cf222e', green: '#1a7f37', yellow: '#9a6700', blue: '#0969da', magenta: '#8250df', cyan: '#1b7c83', white: '#59636e', brightBlack: '#6e7781', brightRed: '#a40e26', brightGreen: '#116329', brightYellow: '#7d4e00', brightBlue: '#0550ae', brightMagenta: '#6639ba', brightCyan: '#116b75', brightWhite: '#1f2328' },
'solarized-light': { background: '#fdf6e3', foreground: '#586e75', cursor: '#147ba3', cursorAccent: '#fdf6e3', selection: 'rgba(38,139,210,0.2)', black: '#eee8d5', red: '#dc322f', green: '#758600', yellow: '#9b7800', blue: '#147ba3', magenta: '#d33682', cyan: '#2a9189', white: '#073642', brightBlack: '#93a1a1', brightRed: '#cb4b16', brightGreen: '#657b83', brightYellow: '#586e75', brightBlue: '#268bd2', brightMagenta: '#6c71c4', brightCyan: '#2aa198', brightWhite: '#002b36' },
'catppuccin-latte': { background: '#eff1f5', foreground: '#4c4f69', cursor: '#1e66f5', cursorAccent: '#ffffff', selection: 'rgba(30,102,245,0.18)', black: '#5c5f77', red: '#d20f39', green: '#3b8f2b', yellow: '#a86605', blue: '#1e66f5', magenta: '#8839ef', cyan: '#177f86', white: '#6c6f85', brightBlack: '#7c7f93', brightRed: '#b50930', brightGreen: '#2f7622', brightYellow: '#8b5604', brightBlue: '#174fbf', brightMagenta: '#6f2bc5', brightCyan: '#116b71', brightWhite: '#4c4f69' },
'rose-pine-dawn': { background: '#faf4ed', foreground: '#575279', cursor: '#286983', cursorAccent: '#fffaf3', selection: 'rgba(40,105,131,0.2)', black: '#575279', red: '#b4637a', green: '#286983', yellow: '#96681f', blue: '#477f91', magenta: '#907aa9', cyan: '#3f7f8b', white: '#6e6a86', brightBlack: '#797593', brightRed: '#984d66', brightGreen: '#1f5266', brightYellow: '#7d5417', brightBlue: '#386b7c', brightMagenta: '#765f90', brightCyan: '#326b76', brightWhite: '#575279' },
};
const CODEMAN_LIGHT_SKINS = new Set(['paper-gray', 'solarized-light', 'catppuccin-latte', 'rose-pine-dawn']);
function currentSkin() {
return (typeof document !== 'undefined' && document.documentElement.dataset.skin) || 'daylight-blue';
}
function currentXtermTheme() {
const skin = (typeof document !== 'undefined' && document.documentElement.dataset.skin) || 'daylight-blue';
const skin = currentSkin();
return CODEMAN_XTERM_THEMES[skin] || CODEMAN_XTERM_THEMES['daylight-blue'];
}
function currentSkinIsLight(skin = currentSkin()) {
return CODEMAN_LIGHT_SKINS.has(skin);
}
global.CodemanTerminalInput = {
isTerminalQueryResponse,
@@ -54,6 +65,7 @@
};
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
global.codemanCurrentXtermTheme = currentXtermTheme;
global.codemanCurrentSkinIsLight = currentSkinIsLight;
})(window);
Object.assign(CodemanApp.prototype, {
@@ -75,6 +87,7 @@ Object.assign(CodemanApp.prototype, {
lineHeight: 1.2,
cursorBlink: false,
cursorStyle: 'block',
minimumContrastRatio: window.codemanCurrentSkinIsLight() ? 4.5 : 1,
scrollback: scrollback,
allowTransparency: true,
allowProposedApi: true,
@@ -969,8 +982,66 @@ Object.assign(CodemanApp.prototype, {
return;
}
// Get line text - translateToString handles wrapped lines
const lineText = line.translateToString(true);
// Stitch the LOGICAL line back together.
//
// xterm invokes this provider per visible ROW, and translateToString returns
// that row alone (the old comment here claimed otherwise). A URL or path
// longer than the terminal is wide therefore matched only as far as the row
// boundary, and the link opened a PREFIX of the real target. Walk out to both
// ends of the continuation, match against the joined text, and map offsets
// back to (x, y) so a link can span rows.
//
// Two different kinds of continuation, and handling only the first is not
// enough:
// 1. SOFT wrap: the emulator ran out of columns and flags the next row
// `isWrapped`.
// 2. HARD wrap: the program did its own wrapping and emitted a real
// newline, so nothing is flagged. Ink does this, which is why Claude
// Code's own `/login` URL was cut at the window edge, and why the
// clickable part grew when the window was widened.
// A row that fills the full width is treated as continuing into the next:
// that is the signal a hard wrap leaves behind, and a line that genuinely
// ended would stop short of the last column.
const cols = self.terminal.cols;
const rowAt = (r) => buffer.getLine(r - 1);
const continuesPrevious = (r) => {
if (r <= 1) return false;
if (rowAt(r)?.isWrapped) return true;
const prev = rowAt(r - 1);
return !!prev && prev.translateToString(true).length >= cols;
};
// Bounded so a screenful of full-width output (wide tables, box drawing)
// cannot make every hover stitch and re-scan the entire viewport.
const MAX_STITCHED_ROWS = 12;
let startRow = bufferLineNumber;
while (startRow > 1 && bufferLineNumber - startRow < MAX_STITCHED_ROWS && continuesPrevious(startRow)) {
startRow--;
}
let endRow = bufferLineNumber;
while (endRow < buffer.length && endRow - startRow < MAX_STITCHED_ROWS && continuesPrevious(endRow + 1)) {
endRow++;
}
const rowTexts = [];
for (let r = startRow; r <= endRow; r++) {
const row = rowAt(r);
if (!row) break;
// Only the final row may be trimmed. Continuation rows fill the width by
// definition, and trimming one would shift every later offset.
rowTexts.push(row.translateToString(r === endRow));
}
const lineText = rowTexts.join('');
/** Map an offset in the stitched text back to a 1-based terminal cell. */
const coordAt = (index) => {
let rest = index;
for (let i = 0; i < rowTexts.length - 1; i++) {
if (rest < rowTexts[i].length) return { x: rest + 1, y: startRow + i };
rest -= rowTexts[i].length;
}
return { x: rest + 1, y: startRow + rowTexts.length - 1 };
};
if (!lineText || !lineText.includes('/')) {
callback(undefined);
@@ -980,22 +1051,27 @@ Object.assign(CodemanApp.prototype, {
const links = [];
// Pattern 0: URLs (https://, http://) — matched first so they take priority
const urlPattern = /https?:\/\/[^\s"'<>|;&)\]\x00-\x1f]+/g;
//
// A single `&` is PART of the URL: it separates query parameters, so excluding
// it truncated every real query string (`?post=1479&action=edit` linked only
// through `1479`, landing on the wrong page). `&&` is still a boundary, since
// that is the shell operator and never appears inside a URL. A lone trailing
// `&` is trimmed below with the other trailing punctuation.
const urlPattern = /https?:\/\/(?:[^\s"'<>|;&)\]\x00-\x1f]|&(?!&))+/g;
const addUrlLink = (url, matchIndex) => {
// Strip trailing punctuation that's likely not part of the URL
const cleaned = url.replace(/[.,;:!?)]+$/, '');
const cleaned = url.replace(/[.,;:!?)&]+$/, '');
const startCol = lineText.indexOf(cleaned, matchIndex);
if (startCol === -1) return;
if (links.some((l) => l.range.start.x === startCol + 1)) return;
const start = coordAt(startCol);
const end = coordAt(startCol + cleaned.length);
if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return;
links.push({
text: cleaned,
range: {
start: { x: startCol + 1, y: bufferLineNumber },
end: { x: startCol + cleaned.length + 1, y: bufferLineNumber },
},
range: { start, end },
decorations: { pointerCursor: true, underline: true },
activate(_event, text) {
window.open(text, '_blank', 'noopener,noreferrer');
@@ -1017,31 +1093,43 @@ Object.assign(CodemanApp.prototype, {
// the whole tab on hover. Non-empty token + bounded reps is O(n).
const cmdPattern = /\b(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]+\s+){0,4}(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
// Pattern 2: Paths with common extensions
// Pattern 2: Paths with common extensions.
// Image/PDF extensions are included so pasted-attachment paths
// (`.claude-images/paste-*.png`) are clickable; they open the file preview
// rather than the log viewer (see addLink).
const extPattern =
/(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
/(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js|png|jpe?g|gif|webp|bmp|svg|pdf))\b/g;
// Pattern 3: Bash() tool output
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
/** Extensions that should open the image/document preview, not the log viewer. */
const PREVIEW_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'pdf']);
const addLink = (filePath, matchIndex) => {
const startCol = lineText.indexOf(filePath, matchIndex);
if (startCol === -1) return;
const start = coordAt(startCol);
const end = coordAt(startCol + filePath.length);
// Skip if already have link at this position
if (links.some((l) => l.range.start.x === startCol + 1)) return;
if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return;
links.push({
text: filePath,
range: {
start: { x: startCol + 1, y: bufferLineNumber }, // 1-based
end: { x: startCol + filePath.length + 1, y: bufferLineNumber },
},
range: { start, end }, // 1-based, may span wrapped rows
decorations: {
pointerCursor: true,
underline: true,
},
activate(event, text) {
// Tailing a PNG in the log viewer shows binary noise; the file preview
// already renders images and PDFs inline.
const ext = (text.split('.').pop() || '').toLowerCase();
if (PREVIEW_EXTS.has(ext)) {
self.openFilePreview(text, self.activeSessionId);
return;
}
self.openLogViewerWindow(text, self.activeSessionId);
},
hover() {
@@ -2418,6 +2506,50 @@ Object.assign(CodemanApp.prototype, {
this.terminal.clear();
},
/** Insert editable text at the active prompt without pressing Enter. */
insertTerminalText(text) {
if (!this.activeSessionId || !text) return;
if (this._localEchoEnabled && this._localEchoOverlay) {
this._localEchoOverlay.appendText(text);
} else {
this.sendInput(text).catch(() => {});
}
this.terminal?.focus();
},
/**
* Clear only the current editable prompt. This is intentionally distinct
* from Ctrl+L (clear display) and the agent's destructive `/clear` command.
*/
clearTerminalInput() {
if (!this.activeSessionId) return;
if (typeof CjkInput !== 'undefined') CjkInput.clear();
if (this._inputFlushTimeout) {
clearTimeout(this._inputFlushTimeout);
this._inputFlushTimeout = null;
}
this._pendingInput = '';
if (this._localEchoEnabled && this._localEchoOverlay) {
const flushed = this._localEchoOverlay.getFlushed?.() || { count: 0, text: '' };
this._localEchoOverlay.clear();
this._localEchoOverlay.suppressBufferDetection();
this._flushedOffsets?.delete(this.activeSessionId);
this._flushedTexts?.delete(this.activeSessionId);
if (flushed.count > 0) {
this.sendInput('\x7f'.repeat(flushed.count)).catch(() => {});
}
} else {
// In non-local-echo mode the TUI already owns the editable buffer. Ctrl+U
// is the conventional kill-line key supported by shells and agent TUIs.
this.sendInput('\x15').catch(() => {});
}
this.showToast?.('Input cleared', 'success');
this.terminal?.focus();
},
/**
* Restore terminal size to match web UI dimensions.
* Use this after mobile screen attachment has squeezed the terminal.
@@ -2846,8 +2978,14 @@ Object.assign(CodemanApp.prototype, {
// DOM and WebGL renderers) plus a belt-and-suspenders refresh().
applyTerminalSkin(skin) {
const theme = { ...(window.CODEMAN_XTERM_THEMES[skin] || window.CODEMAN_XTERM_THEMES['daylight-blue']) };
const minimumContrastRatio = window.codemanCurrentSkinIsLight(skin) ? 4.5 : 1;
if (this.terminal) {
this.terminal.options.minimumContrastRatio = minimumContrastRatio;
this.terminal.options.theme = theme;
// The zero-lag typing overlay caches the xterm foreground/background.
// Refresh it on live skin changes so typed text never keeps the prior
// theme's dark backing surface or foreground color.
this._localEchoOverlay?.refreshFont();
try {
this.terminal.refresh(0, this.terminal.rows - 1);
} catch {}
@@ -2855,6 +2993,7 @@ Object.assign(CodemanApp.prototype, {
if (this.teammateTerminals) {
for (const [, entry] of this.teammateTerminals) {
if (entry && entry.terminal) {
entry.terminal.options.minimumContrastRatio = minimumContrastRatio;
entry.terminal.options.theme = { ...theme };
try {
entry.terminal.refresh(0, entry.terminal.rows - 1);
+445
View File
@@ -0,0 +1,445 @@
/**
* @fileoverview Web tabs: saved dashboard URLs rendered as tabs beside agent
* sessions, so Codeman is one mission control instead of Codeman plus a pile of
* browser tabs.
*
* Each open dashboard is an <iframe> inside #webviewLayer, which covers the
* terminal while a web tab is active. Frames stay MOUNTED while hidden, because a
* dashboard that reloads and re-authenticates on every tab switch is worse than
* the browser tab it replaced. `maxLiveFrames` (from the server) bounds that with
* least-recently-viewed eviction.
*
* Sandboxing: a proxied dashboard is served from Codeman's own origin, so the
* iframe deliberately omits `allow-same-origin` unless the dashboard is marked
* trusted. Without that omission the page could read this document and call the
* API that spawns agents.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js, api-client.js, constants.js (escapeHtml)
* @loadorder 12.5 of 16, after session-ui.js (needs the tab strip), before api-client.js
*/
Object.assign(CodemanApp.prototype, {
// ── State ─────────────────────────────────────────────────────────────────
/** Load the saved list and restore which tabs were open. */
async initWebviews() {
this.webviews = this.webviews || new Map();
this.webviewOrder = this.webviewOrder || [];
this.activeWebviewId = this.activeWebviewId || null;
this._webviewMaxFrames = this._webviewMaxFrames || 6;
this._webviewFrameLru = this._webviewFrameLru || [];
await this.refreshWebviews();
// Restore the previously open web tabs (per device: which dashboards you keep
// open is a workspace-layout choice, not something to sync across machines).
let saved = [];
try {
saved = JSON.parse(localStorage.getItem('codeman-webview-order') || '[]');
} catch {
saved = [];
}
this.webviewOrder = saved.filter((id) => this.webviews.has(id));
this.renderSessionTabs();
},
async refreshWebviews() {
const data = await this._apiJson('/api/webviews');
if (!data) return;
this.webviews = new Map((data.webviews || []).map((w) => [w.id, w]));
if (typeof data.maxLiveFrames === 'number') this._webviewMaxFrames = data.maxLiveFrames;
this.renderWebviewMenuItems();
},
/** SSE: the saved list changed (possibly on another device). */
async _onWebviewChanged(data) {
await this.refreshWebviews();
// A dashboard deleted elsewhere must not linger as a dead tab here.
if (data && data.action === 'deleted' && data.id) this._removeWebviewTab(data.id);
this.renderSessionTabs();
},
_persistWebviewOrder() {
try {
localStorage.setItem('codeman-webview-order', JSON.stringify(this.webviewOrder || []));
} catch {
/* private mode / quota, order is a convenience, never fatal */
}
},
// ── Tab strip ─────────────────────────────────────────────────────────────
/**
* Tab HTML for every OPEN web tab, appended by _fullRenderSessionTabs().
* `startIndex` continues the Alt+N numbering after the session tabs.
*/
renderWebviewTabs(startIndex) {
if (!this.webviewOrder || this.webviewOrder.length === 0) return '';
const parts = [];
let idx = startIndex;
for (const id of this.webviewOrder) {
const webview = this.webviews.get(id);
if (!webview) continue;
const isActive = id === this.activeWebviewId;
const jsonId = escapeHtml(JSON.stringify(id));
const icon = webview.icon ? escapeHtml(webview.icon) : '';
parts.push(`<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
onclick="app.handleWebviewTabClick(event, ${jsonId})"
tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}"
aria-label="${escapeHtml(webview.name)} web tab" title="${escapeHtml(webview.url)}">
${idx < 9 ? '<span class="tab-number">' + (idx + 1) + '</span>' : ''}
<span class="tab-web-icon" aria-hidden="true">${icon || this._webviewGlobeIcon()}</span>
<span class="tab-info">
<span class="tab-name-row">
<span class="tab-name">${escapeHtml(webview.name)}</span>
</span>
</span>
<span class="tab-gear" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})" title="URL settings" aria-label="URL settings" tabindex="0">&#x2699;</span>
<span class="tab-close" onclick="event.stopPropagation(); app.closeWebviewTab(${jsonId})" title="Close tab" aria-label="Close web tab" tabindex="0">&times;</span>
</div>`);
idx++;
}
return parts.join('');
},
_webviewGlobeIcon() {
return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20"/></svg>';
},
handleWebviewTabClick(event, id) {
event?.preventDefault?.();
return this.openWebview(id);
},
/** Mark exactly one tab active across BOTH tab kinds. */
_updateActiveWebviewTab() {
const container = this.$('sessionTabs');
if (!container) return;
for (const tab of container.querySelectorAll('.session-tab[data-webview-id]')) {
tab.classList.toggle('active', tab.dataset.webviewId === this.activeWebviewId);
}
if (this.activeWebviewId) {
// A web tab is active, so no session tab may also look active.
for (const tab of container.querySelectorAll('.session-tab[data-id]')) tab.classList.remove('active');
}
},
// ── Opening / closing ─────────────────────────────────────────────────────
/**
* Open (or focus) a dashboard tab. Mints a fresh capability every time: they are
* memory-only and expire, so a tab reopened after a server restart must not reuse
* the dead URL from the previous run.
*/
async openWebview(id) {
const webview = this.webviews.get(id);
if (!webview) return;
if (!this.webviewOrder.includes(id)) {
this.webviewOrder.push(id);
this._persistWebviewOrder();
}
const data = await this._apiJson(`/api/webviews/${encodeURIComponent(id)}/open`, { method: 'POST' });
if (!data) {
this.showToast?.('Could not open URL', 'error');
return;
}
if (data.webview) this.webviews.set(id, data.webview);
const src = data.embedUrl || data.webview?.url || webview.url;
this._mountWebviewFrame(id, src, data.webview || webview);
this.activeWebviewId = id;
this.hideWelcome?.();
document.querySelector('.main')?.classList.add('webview-active');
this.renderSessionTabs();
this._updateActiveWebviewTab();
},
/** Create the frame if absent, then reveal it and hide its siblings. */
_mountWebviewFrame(id, src, webview) {
const layer = document.getElementById('webviewLayer');
if (!layer) return;
let wrap = layer.querySelector(`.webview-frame[data-webview-id="${CSS.escape(id)}"]`);
if (!wrap) {
wrap = document.createElement('div');
wrap.className = 'webview-frame';
wrap.dataset.webviewId = id;
const frame = document.createElement('iframe');
frame.className = 'webview-iframe';
frame.setAttribute('title', webview.name);
// No allow-same-origin unless explicitly trusted: a proxied page is served
// from THIS origin, so granting it would let the dashboard read this document
// and drive the Codeman API.
const sandbox = ['allow-scripts', 'allow-forms', 'allow-popups', 'allow-downloads', 'allow-modals'];
if (webview.trusted) sandbox.push('allow-same-origin');
frame.setAttribute('sandbox', sandbox.join(' '));
frame.setAttribute('referrerpolicy', 'no-referrer-when-downgrade');
frame.src = src;
const failure = document.createElement('div');
failure.className = 'webview-failure';
failure.innerHTML = this._webviewFailureHtml(id);
wrap.appendChild(frame);
wrap.appendChild(failure);
layer.appendChild(wrap);
// A frame that never fires `load` is the normal symptom of a refused embed or
// an unreachable host. Show an actionable panel instead of a blank rectangle.
const timer = setTimeout(() => wrap.classList.add('webview-frame--failed'), 8000);
frame.addEventListener('load', () => {
clearTimeout(timer);
wrap.classList.remove('webview-frame--failed');
});
}
this._touchWebviewFrame(id);
for (const other of layer.querySelectorAll('.webview-frame')) {
other.classList.toggle('active', other.dataset.webviewId === id);
}
// Chart libraries measure on resize; a frame revealed from display:none needs the nudge.
requestAnimationFrame(() => window.dispatchEvent(new Event('resize')));
},
_webviewFailureHtml(id) {
const jsonId = escapeHtml(JSON.stringify(id));
return `<div class="webview-failure-inner">
<h3>This URL did not load</h3>
<p>It may be unreachable from the Codeman server, or it may refuse to be embedded.</p>
<div class="webview-failure-actions">
<button class="btn-secondary" onclick="app.reloadWebview(${jsonId})">Reload</button>
<button class="btn-secondary" onclick="app.openWebviewExternal(${jsonId})">Open in new tab</button>
<button class="btn-secondary" onclick="app.showWebviewModal(${jsonId})">Edit</button>
</div>
</div>`;
},
/** Least-recently-viewed eviction so N open dashboards cannot pin N live pages. */
_touchWebviewFrame(id) {
this._webviewFrameLru = (this._webviewFrameLru || []).filter((x) => x !== id);
this._webviewFrameLru.push(id);
const layer = document.getElementById('webviewLayer');
if (!layer) return;
while (this._webviewFrameLru.length > this._webviewMaxFrames) {
const evict = this._webviewFrameLru.shift();
if (evict === this.activeWebviewId) continue;
layer.querySelector(`.webview-frame[data-webview-id="${CSS.escape(evict)}"]`)?.remove();
}
},
reloadWebview(id) {
const target = id || this.activeWebviewId;
if (!target) return;
document
.getElementById('webviewLayer')
?.querySelector(`.webview-frame[data-webview-id="${CSS.escape(target)}"]`)
?.remove();
this._webviewFrameLru = (this._webviewFrameLru || []).filter((x) => x !== target);
return this.openWebview(target);
},
openWebviewExternal(id) {
const webview = this.webviews.get(id || this.activeWebviewId);
if (webview) window.open(webview.url, '_blank', 'noopener');
},
closeWebviewTab(id) {
this._removeWebviewTab(id);
this.renderSessionTabs();
},
_removeWebviewTab(id) {
this.webviewOrder = (this.webviewOrder || []).filter((x) => x !== id);
this._webviewFrameLru = (this._webviewFrameLru || []).filter((x) => x !== id);
this._persistWebviewOrder();
document
.getElementById('webviewLayer')
?.querySelector(`.webview-frame[data-webview-id="${CSS.escape(id)}"]`)
?.remove();
if (this.activeWebviewId === id) {
this.activeWebviewId = null;
const next = this.webviewOrder[0];
if (next) {
this.openWebview(next);
} else {
this._hideWebviewLayer();
// Fall back to whatever session was last shown, or the welcome screen.
if (this.activeSessionId) this._updateActiveTabImmediate(this.activeSessionId);
else this.showWelcome?.();
}
}
},
/** Called by selectSession(): a session tab takes the stage back from a web tab. */
_hideWebviewLayer() {
if (!this.activeWebviewId && !document.querySelector('.main.webview-active')) return;
this.activeWebviewId = null;
document.querySelector('.main')?.classList.remove('webview-active');
for (const frame of document.querySelectorAll('#webviewLayer .webview-frame')) {
frame.classList.remove('active');
}
this._updateActiveWebviewTab();
},
// ── Run-menu entries ──────────────────────────────────────────────────────
/** Saved dashboards listed inside the Run dropdown, under "Web / URL". */
renderWebviewMenuItems() {
const container = document.getElementById('runModeWebviews');
if (!container) return;
const list = [...(this.webviews?.values() || [])];
if (list.length === 0) {
container.innerHTML = '<div class="run-mode-empty">No URLs yet</div>';
return;
}
container.innerHTML = list
.map(
(w) => `<button class="run-mode-option run-mode-option--web" onclick="app.openWebviewFromMenu(${escapeHtml(
JSON.stringify(w.id)
)})" title="${escapeHtml(w.url)}">
<span class="run-mode-menu-icon">${w.icon ? escapeHtml(w.icon) : '<span class="run-mode-dot web"></span>'}</span>${escapeHtml(
w.name
)}
</button>`
)
.join('');
},
openWebviewFromMenu(id) {
document.getElementById('runModeMenu')?.classList.remove('active');
return this.openWebview(id);
},
// ── Icon picker ───────────────────────────────────────────────────────────
/** Common dashboard/service glyphs. The text field stays open for anything else. */
_webviewIconChoices() {
return ['📊', '📈', '🖥️', '🎛️', '📡', '🐳', '🗄️', '🔒', '🌐', '📁', '🧪', '🧬', '⚡', '🔔', '📝', '🎧'];
},
_renderWebviewIconPicker(selected) {
const picker = document.getElementById('webviewIconPicker');
if (!picker) return;
picker.innerHTML = this._webviewIconChoices()
.map(
(icon) =>
`<button type="button" class="webview-icon-choice${icon === selected ? ' selected' : ''}"
onclick="app.pickWebviewIcon(${escapeHtml(JSON.stringify(icon))})"
aria-label="Use ${escapeHtml(icon)} as the icon">${escapeHtml(icon)}</button>`
)
.join('');
},
/** Clicking the selected icon again clears it, so there is a way back to no icon. */
pickWebviewIcon(icon) {
const field = document.getElementById('webviewIcon');
if (!field) return;
field.value = field.value === icon ? '' : icon;
this._renderWebviewIconPicker(field.value);
},
// ── Editor modal ──────────────────────────────────────────────────────────
showWebviewModal(id) {
const modal = document.getElementById('webviewModal');
if (!modal) return;
const webview = id ? this.webviews.get(id) : null;
this._editingWebviewId = webview ? webview.id : null;
document.getElementById('webviewModalTitle').textContent = webview ? 'Edit URL' : 'Add URL';
this._renderWebviewIconPicker(webview?.icon || '');
document.getElementById('webviewName').value = webview?.name || '';
document.getElementById('webviewUrl').value = webview?.url || '';
document.getElementById('webviewIcon').value = webview?.icon || '';
document.getElementById('webviewSandboxed').checked = !webview?.trusted;
document.getElementById('webviewProbeResult').textContent = '';
document.getElementById('webviewDeleteBtn').style.display = webview ? '' : 'none';
document.getElementById('runModeMenu')?.classList.remove('active');
modal.classList.add('active');
document.getElementById('webviewName').focus();
},
closeWebviewModal() {
document.getElementById('webviewModal')?.classList.remove('active');
this._editingWebviewId = null;
},
/** Server-side probe: it runs from the network position the proxy will use. */
async testWebviewUrl() {
const url = document.getElementById('webviewUrl').value.trim();
const out = document.getElementById('webviewProbeResult');
if (!url) {
out.textContent = 'Enter a URL first.';
return;
}
out.textContent = 'Testing...';
const probe = await this._apiJson('/api/webviews/probe', { method: 'POST', body: { url } });
if (!probe) {
out.textContent = 'Test failed (invalid URL?).';
return;
}
out.textContent = probe.reachable
? `Reachable (HTTP ${probe.status}). ${probe.reason}`
: `Not reachable. ${probe.reason}`;
out.className = 'form-hint webview-probe-result ' + (probe.reachable ? 'ok' : 'bad');
},
async saveWebview() {
const name = document.getElementById('webviewName').value.trim();
const url = document.getElementById('webviewUrl').value.trim();
const icon = document.getElementById('webviewIcon').value.trim();
const trusted = !document.getElementById('webviewSandboxed').checked;
if (!name || !url) {
this.showToast?.('Name and URL are required', 'error');
return;
}
// `icon: undefined` rather than null, the schema uses .optional(), which
// rejects an explicit null on the wire.
const body = { name, url, icon: icon || undefined, trusted };
const editing = this._editingWebviewId;
const data = editing
? await this._apiJson(`/api/webviews/${encodeURIComponent(editing)}`, { method: 'PATCH', body })
: await this._apiJson('/api/webviews', { method: 'POST', body });
if (!data) {
this.showToast?.('Could not save (check the URL)', 'error');
return;
}
await this.refreshWebviews();
this.closeWebviewModal();
if (editing) {
// The capability was revoked server-side by the edit, so a mounted frame is
// now pointing at a dead URL. Remount it.
if (this.webviewOrder.includes(editing)) this.reloadWebview(editing);
} else {
this.openWebview(data.id);
}
},
async deleteWebview() {
const id = this._editingWebviewId;
if (!id) return;
const webview = this.webviews.get(id);
if (!confirm(`Delete "${webview?.name || id}"?`)) return;
const res = await this._apiDelete(`/api/webviews/${encodeURIComponent(id)}`);
if (!res || !res.ok) {
this.showToast?.('Could not delete URL', 'error');
return;
}
this._removeWebviewTab(id);
this.webviews.delete(id);
this.closeWebviewModal();
this.renderWebviewMenuItems();
this.renderSessionTabs();
},
});
+370 -8
View File
@@ -4,9 +4,17 @@
*/
import { FastifyInstance, type FastifyReply } from 'fastify';
import { basename as pathBasename, join } from 'node:path';
import { basename as pathBasename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { createReadStream, realpathSync, type ReadStream } from 'node:fs';
import fs from 'node:fs/promises';
import { homedir } from 'node:os';
import type {
ApiResponse,
FilesystemBrowseData,
FilesystemBrowseEntry,
FilesystemBrowseRoot,
FilesystemPreviewKind,
} from '../../types.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { fileStreamManager } from '../../file-stream-manager.js';
import {
@@ -22,12 +30,21 @@ import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
import { canAccessOwned, findSessionOrFail, getAuthUser, validateSessionFilePath } from '../route-helpers.js';
import { isMultiUserMode, userSpacePath } from '../../config/multiuser.js';
import {
CASES_DIR,
canAccessOwned,
findSessionOrFail,
getAuthUser,
parseBody,
validateSessionFilePath,
} from '../route-helpers.js';
import type { FastifyRequest } from 'fastify';
import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js';
import { isSensitivePath } from '../sensitive-path.js';
import { SseEvent } from '../sse-events.js';
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js';
import { FilesystemBrowseQuerySchema, FilesystemPreviewQuerySchema } from '../schemas.js';
const MIME_TYPES: Record<string, string> = {
png: 'image/png',
@@ -45,8 +62,14 @@ const MIME_TYPES: Record<string, string> = {
txt: 'text/plain',
};
function sanitizeDownloadName(fileName: string): string {
return fileName.replace(/["\\\r\n]/g, '_');
function buildContentDisposition(disposition: 'inline' | 'attachment', fileName: string): string {
const cleaned = fileName.replace(/["\\\r\n]/g, '_');
const fallback = cleaned.replace(/[^\x20-\x7e]/g, '_') || 'file';
const encoded = encodeURIComponent(cleaned).replace(
/['()*]/g,
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
);
return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encoded}`;
}
function sendRawStream(reply: FastifyReply, content: ReadStream): void {
@@ -92,13 +115,12 @@ async function serveRawFile(
return;
}
const content = createReadStream(resolvedPath);
const safeName = sanitizeDownloadName(fileName);
if (download || extension === 'svg') {
reply.header(
'Content-Type',
extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream'
);
reply.header('Content-Disposition', `attachment; filename="${safeName}"`);
reply.header('Content-Disposition', buildContentDisposition('attachment', fileName));
reply.header('Content-Length', stat.size);
reply.header('X-Content-Type-Options', 'nosniff');
sendRawStream(reply, content);
@@ -106,7 +128,7 @@ async function serveRawFile(
}
reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream');
reply.header('Content-Disposition', `inline; filename="${safeName}"`);
reply.header('Content-Disposition', buildContentDisposition('inline', fileName));
reply.header('Content-Length', stat.size);
reply.header('X-Content-Type-Options', 'nosniff');
sendRawStream(reply, content);
@@ -194,7 +216,10 @@ async function serveConvertedPreview(
const content = await fs.readFile(previewPath);
reply.header('Content-Type', 'application/pdf');
reply.header('Content-Disposition', `inline; filename="${getPreviewPdfDownloadName(fileName, extension)}"`);
reply.header(
'Content-Disposition',
buildContentDisposition('inline', getPreviewPdfDownloadName(fileName, extension))
);
reply.header('Cache-Control', 'no-cache');
reply.header('Content-Length', content.length);
reply.header('X-Content-Type-Options', 'nosniff');
@@ -260,6 +285,170 @@ type AttachmentHistoryRouteItem = Omit<SessionAttachmentHistoryItem, 'externalPa
attachmentId?: string;
};
const FILESYSTEM_PICKER_ENTRY_LIMIT = 500;
const FILESYSTEM_TEXT_PREVIEW_LIMIT = 2 * 1024 * 1024;
const FILESYSTEM_BINARY_PREVIEW_LIMIT = 50 * 1024 * 1024;
const FILESYSTEM_IMAGE_PREVIEW_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']);
const FILESYSTEM_TEXT_PREVIEW_EXTENSIONS = new Set(['md', 'txt', 'json']);
const FILESYSTEM_DOCUMENT_PREVIEW_EXTENSIONS = new Set(['pdf', 'docx', 'pptx']);
function isPathWithinRoot(root: string, candidate: string): boolean {
const rel = relative(root, candidate);
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
}
function findMatchingPickerRoot(roots: FilesystemBrowseRoot[], candidate: string): FilesystemBrowseRoot | undefined {
return roots
.filter((root) => isPathWithinRoot(root.path, candidate))
.sort((a, b) => b.path.length - a.path.length)[0];
}
function containsHiddenPickerSegment(root: string, candidate: string): boolean {
const rel = relative(root, candidate);
return rel !== '' && rel.split(sep).some((segment) => segment.startsWith('.'));
}
function getFilesystemPreviewKind(fileName: string): FilesystemPreviewKind | undefined {
const extension = extname(fileName).slice(1).toLowerCase();
if (FILESYSTEM_IMAGE_PREVIEW_EXTENSIONS.has(extension)) return 'image';
if (FILESYSTEM_TEXT_PREVIEW_EXTENSIONS.has(extension)) return 'text';
if (FILESYSTEM_DOCUMENT_PREVIEW_EXTENSIONS.has(extension)) return 'document';
return undefined;
}
function isBlockedPickerPath(path: string, blockedTrees: readonly string[], directory = false): boolean {
if (isBlockedAttachmentPath(path, blockedTrees)) return true;
// The shared sensitive-path matcher describes file locations such as
// ~/.ssh/<key>. Probe a child path as well so the directory itself cannot be
// opened and used to enumerate those filenames.
return directory && isBlockedAttachmentPath(join(path, '__codeman_path_picker_probe__'), blockedTrees);
}
function extraConfiguredPickerRoots(): Array<{ label: string; path: string }> {
const extraRoots = process.env.CODEMAN_FILE_PICKER_ROOTS;
if (!extraRoots) return [];
return extraRoots
.split(',')
.map((value) => value.trim())
.filter(Boolean)
.map((path, index) => ({ label: `Configured ${index + 1}`, path }));
}
/**
* Browse roots for the requesting identity.
*
* Single-user mode (and multi-user admins) get the host-wide set. ⚠️ A regular
* multi-user user must NOT: per-user spaces live at `<USER_SPACES_DIR>/<name>`,
* which is *inside* `homedir()`, so handing out a `Home` root would let any
* authenticated user browse and preview every other user's workspace. The
* shared `CASES_DIR` leaks the same way, and `/mnt/d` is a broad host mount
* that a multi-user deployment should not expose by default. Operators who
* genuinely want a shared area can still name it in `CODEMAN_FILE_PICKER_ROOTS`,
* which stays an explicit opt-in in both modes.
*/
function configuredFilesystemPickerRoots(req: FastifyRequest): Array<{ label: string; path: string }> {
const user = getAuthUser(req);
if (isMultiUserMode() && user.role !== 'admin') {
return [{ label: 'My Space', path: userSpacePath(user.username) }, ...extraConfiguredPickerRoots()];
}
return [
{ label: 'Home', path: homedir() },
{ label: 'Codeman Cases', path: CASES_DIR },
{ label: 'WSL D:', path: '/mnt/d' },
...extraConfiguredPickerRoots(),
];
}
async function resolveFilesystemPickerRoots(
ctx: SessionPort & ConfigPort,
req: FastifyRequest,
sessionId?: string
): Promise<FilesystemBrowseRoot[]> {
const candidates = configuredFilesystemPickerRoots(req);
if (sessionId) {
const session = ctx.sessions.get(sessionId) ?? ctx.store.getSession(sessionId);
// ⚠️ Ownership must be checked here, exactly as `findSessionOrFail` does for
// the other session-scoped handlers in this file. Without it a multi-user
// caller could pin ANOTHER user's `workingDir` as a browse root just by
// passing their sessionId. Report not-found rather than forbidden so the
// endpoint does not confirm that a session id exists.
if (!session || !canAccessOwned(getAuthUser(req), (session as { owner?: string }).owner)) {
throw Object.assign(new Error(`Session ${sessionId} not found`), {
statusCode: 404,
body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`),
});
}
candidates.unshift({ label: 'Current Folder', path: session.workingDir });
}
const guard = await loadAttachmentGuardConfig();
const roots: FilesystemBrowseRoot[] = [];
const seen = new Set<string>();
for (const candidate of candidates) {
if (!isAbsolute(candidate.path)) continue;
try {
const resolved = realpathSync(candidate.path);
if (seen.has(resolved) || isBlockedPickerPath(resolved, guard.blockedTrees, true)) continue;
const stat = await fs.stat(resolved);
if (!stat.isDirectory()) continue;
seen.add(resolved);
roots.push({ label: candidate.label, path: resolved });
} catch {
// Optional roots (for example /mnt/d on non-WSL hosts) are omitted.
}
}
return roots;
}
type ResolvedFilesystemPickerPath = {
candidatePath: string;
resolvedPath: string;
roots: FilesystemBrowseRoot[];
matchingRoot: FilesystemBrowseRoot;
blockedTrees: readonly string[];
};
function throwFilesystemPickerError(statusCode: number, code: ApiErrorCode, message: string): never {
throw Object.assign(new Error(message), {
statusCode,
body: createErrorResponse(code, message),
});
}
async function resolveFilesystemPickerPath(
ctx: SessionPort & ConfigPort,
req: FastifyRequest,
requestedPath: string | undefined,
sessionId?: string
): Promise<ResolvedFilesystemPickerPath> {
const roots = await resolveFilesystemPickerRoots(ctx, req, sessionId);
if (roots.length === 0) {
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'No filesystem browse roots are available');
}
const fallbackRoot =
roots.find((root) => root.label === 'Current Folder') ?? roots.find((root) => root.path === '/mnt/d') ?? roots[0];
const candidatePath = resolve(requestedPath ?? fallbackRoot.path);
let resolvedPath: string;
try {
resolvedPath = realpathSync(candidatePath);
} catch {
throwFilesystemPickerError(404, ApiErrorCode.NOT_FOUND, `Path not found: ${candidatePath}`);
}
const matchingRoot = findMatchingPickerRoot(roots, resolvedPath);
if (!matchingRoot) {
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Path is outside the allowed browse roots');
}
if (containsHiddenPickerSegment(matchingRoot.path, resolvedPath)) {
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Hidden paths are not available in the file picker');
}
const guard = await loadAttachmentGuardConfig();
return { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees: guard.blockedTrees };
}
function appendDownloadFlag(url: string): string {
return `${url}${url.includes('?') ? '&' : '?'}download=true`;
}
@@ -375,6 +564,179 @@ async function buildExternalAttachmentRouteItem(
}
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort): void {
// Lazy filesystem listing for the Link Existing and mobile input path pickers.
app.get('/api/filesystem/browse', async (req, reply): Promise<ApiResponse<FilesystemBrowseData>> => {
const { path: requestedPath, sessionId } = parseBody(FilesystemBrowseQuerySchema, req.query);
const { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees } = await resolveFilesystemPickerPath(
ctx,
req,
requestedPath,
sessionId
);
if (isBlockedPickerPath(resolvedPath, blockedTrees, true)) {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this folder is blocked');
}
try {
const stat = await fs.stat(resolvedPath);
if (!stat.isDirectory()) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'The browse path must be a directory');
}
} catch {
reply.code(404);
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${candidatePath}`);
}
let dirEntries;
try {
dirEntries = await fs.readdir(resolvedPath, { withFileTypes: true });
} catch {
reply.code(403);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'This folder cannot be read');
}
dirEntries.sort((a, b) => {
if (a.isDirectory() && !b.isDirectory()) return -1;
if (!a.isDirectory() && b.isDirectory()) return 1;
return a.name.localeCompare(b.name);
});
const entries: FilesystemBrowseEntry[] = [];
let truncated = false;
for (const entry of dirEntries) {
if (entry.name.startsWith('.')) continue;
if (entries.length >= FILESYSTEM_PICKER_ENTRY_LIMIT) {
truncated = true;
break;
}
const visiblePath = join(candidatePath, entry.name);
let targetPath: string;
try {
targetPath = realpathSync(visiblePath);
} catch {
continue;
}
const targetRoot = findMatchingPickerRoot(roots, targetPath);
if (!targetRoot || containsHiddenPickerSegment(targetRoot.path, targetPath)) continue;
let type: FilesystemBrowseEntry['type'];
let size: number | undefined;
const symlink = entry.isSymbolicLink();
if (entry.isDirectory()) {
type = 'directory';
} else if (entry.isFile()) {
type = 'file';
} else if (symlink) {
try {
const targetStat = await fs.stat(targetPath);
type = targetStat.isDirectory() ? 'directory' : 'file';
if (type === 'file') size = targetStat.size;
} catch {
continue;
}
} else {
continue;
}
if (isBlockedPickerPath(targetPath, blockedTrees, type === 'directory')) continue;
if (type === 'file' && size === undefined) {
try {
size = (await fs.stat(targetPath)).size;
} catch {
// The path is still selectable even when a size lookup races a change.
}
}
entries.push({
name: entry.name,
path: visiblePath,
type,
size,
symlink: symlink || undefined,
previewKind: type === 'file' ? getFilesystemPreviewKind(entry.name) : undefined,
});
}
const parentCandidate = resolve(candidatePath, '..');
let parent: string | null = null;
if (candidatePath !== matchingRoot.path) {
try {
const resolvedParent = realpathSync(parentCandidate);
if (isPathWithinRoot(matchingRoot.path, resolvedParent)) parent = parentCandidate;
} catch {
// A concurrently removed parent simply disables upward navigation.
}
}
return {
success: true,
data: {
path: candidatePath,
parent,
root: matchingRoot.path,
roots,
entries,
truncated,
},
};
});
// Inline preview for files selected through the root-confined filesystem picker.
app.get('/api/filesystem/preview', { compress: false }, async (req, reply): Promise<void> => {
const { path: requestedPath, sessionId } = parseBody(FilesystemPreviewQuerySchema, req.query);
const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath(
ctx,
req,
requestedPath,
sessionId
);
if (isBlockedPickerPath(resolvedPath, blockedTrees)) {
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked');
}
let stat;
try {
stat = await fs.stat(resolvedPath);
} catch {
throwFilesystemPickerError(404, ApiErrorCode.NOT_FOUND, `File not found: ${candidatePath}`);
}
if (!stat.isFile()) {
throwFilesystemPickerError(400, ApiErrorCode.INVALID_INPUT, 'The preview path must be a file');
}
const fileName = pathBasename(candidatePath);
const extension = extname(fileName).slice(1).toLowerCase();
const previewKind = getFilesystemPreviewKind(fileName);
if (!previewKind) {
throwFilesystemPickerError(400, ApiErrorCode.INVALID_INPUT, 'This file type cannot be previewed');
}
const sizeLimit = previewKind === 'text' ? FILESYSTEM_TEXT_PREVIEW_LIMIT : FILESYSTEM_BINARY_PREVIEW_LIMIT;
if (stat.size > sizeLimit) {
throwFilesystemPickerError(
413,
ApiErrorCode.INVALID_INPUT,
`File too large to preview (${Math.ceil(stat.size / 1024 / 1024)}MB limit: ${sizeLimit / 1024 / 1024}MB)`
);
}
reply.header('Cache-Control', 'no-cache');
reply.header('X-Content-Type-Options', 'nosniff');
if (previewKind === 'text') {
const content = await fs.readFile(resolvedPath, 'utf8');
reply.type('text/plain; charset=utf-8').send(content);
return;
}
if (extension === 'docx' || extension === 'pptx') {
await serveConvertedPreview(reply, resolvedPath, fileName, extension);
return;
}
await serveRawFile(reply, resolvedPath, fileName, extension);
});
// File tree listing
app.get('/api/sessions/:id/files', async (req) => {
const { id } = req.params as { id: string };
+1
View File
@@ -22,3 +22,4 @@ export { registerSearchRoutes } from './search-routes.js';
export { registerMeRoutes } from './me-routes.js';
export { registerAdminRoutes } from './admin-routes.js';
export { registerWsRoutes } from './ws-routes.js';
export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
+629
View File
@@ -0,0 +1,629 @@
/**
* @fileoverview Web tabs: saved dashboard URLs, plus the reverse proxy that makes
* them embeddable.
*
* Two distinct surfaces live here, and the split matters:
*
* 1. `/api/webviews/*`, ordinary authenticated CRUD, owner-scoped like every
* other resource, returning the `ApiResponse` envelope.
* 2. `/webview/:cap/*`, the proxy. NOT an API surface. It authenticates on an
* unguessable capability in the path instead of Codeman's session cookie, and
* is correspondingly exempt from the cookie and Origin checks in
* `middleware/auth.ts`. See `src/webview-capabilities.ts` for why a cookie
* cannot work here (sandboxed iframes are opaque-origin, so their requests are
* cross-site and arrive with `Origin: null`).
*
* The proxy is registered inside an ENCAPSULATED plugin scope with its own
* catch-all content-type parser. Fastify scopes parsers to the plugin that
* registers them, which is what lets the proxy forward raw request bodies
* upstream while the rest of the app keeps its JSON parsing (and, critically,
* keeps `text/plain` raw, auto-parsing that was a real CSRF hole once).
*
* Endpoints:
* GET /api/webviews
* POST /api/webviews
* PATCH /api/webviews/:id
* DELETE /api/webviews/:id
* POST /api/webviews/probe
* POST /api/webviews/:id/open
* ALL /webview/:cap/* (+ WebSocket upgrade on GET)
*/
import { randomUUID } from 'node:crypto';
import { Readable } from 'node:stream';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { WebSocket as WsClient } from 'ws';
import type { WebSocket } from 'ws';
import { getDataDir } from '../../config/instance.js';
import {
MAX_LIVE_WEBVIEW_FRAMES,
MAX_WEBVIEWS,
MAX_WEBVIEW_HTML_REWRITE_BYTES,
MAX_WEBVIEW_SOCKETS,
WEBVIEW_PROBE_TIMEOUT_MS,
WEBVIEW_PROXY_PREFIX,
WEBVIEW_UPSTREAM_TIMEOUT_MS,
} from '../../config/webview-limits.js';
import { readWebviews, writeWebviews } from '../../webview-store.js';
import { webviewCapabilities } from '../../webview-capabilities.js';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import type { Webview, WebviewOpenData, WebviewProbe } from '../../types.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
import { canAccessOwned, getAuthUser, ownerFor, parseBody } from '../route-helpers.js';
import { WebviewCreateSchema, WebviewProbeSchema, WebviewUpdateSchema } from '../schemas.js';
import { SseEvent } from '../sse-events.js';
import type { EventPort } from '../ports/index.js';
import {
buildDownstreamResponseHeaders,
buildProxyCorsHeaders,
buildUpstreamRequestHeaders,
capabilityFromReferer,
extractFrameAncestors,
isFramableCrossOrigin,
isHtmlContentType,
parseWebviewUrl,
proxyPrefixFor,
resolveUpstreamUrl,
rewriteHtml,
upstreamWebSocketUrl,
} from '../webview-proxy.js';
/**
* Resolved per call rather than captured at module load. `getDataDir()` reads
* `CODEMAN_DATA_DIR` each time, so a lazy lookup keeps tests writing to a temp dir
* instead of the developer's real `~/.codeman/webviews.json`.
*/
function configDir(): string {
return getDataDir();
}
/** Live proxied WebSockets per webview id, so one dashboard cannot exhaust the socket budget. */
const socketCounts = new Map<string, number>();
interface ProxyParams {
cap: string;
'*'?: string;
}
/** Serialize webview mutations: read-modify-write on a shared JSON file otherwise races. */
let writeChain: Promise<unknown> = Promise.resolve();
function withWebviews<T>(fn: (list: Webview[]) => Promise<T> | T): Promise<T> {
const next = writeChain.then(async () => {
const list = await readWebviews(configDir());
return fn(list);
});
// Keep the chain alive even if this link rejects, or every later write deadlocks.
writeChain = next.catch(() => undefined);
return next;
}
export function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort): void {
registerCrudRoutes(app, ctx);
registerProxyRoutes(app);
}
// ───────────────────────────── CRUD ─────────────────────────────
function registerCrudRoutes(app: FastifyInstance, ctx: EventPort): void {
app.get('/api/webviews', async (req) => {
const user = getAuthUser(req);
const all = await readWebviews(configDir());
const webviews = all.filter((w) => canAccessOwned(user, w.owner));
return { success: true, data: { webviews, maxLiveFrames: MAX_LIVE_WEBVIEW_FRAMES } };
});
app.post('/api/webviews', async (req, reply) => {
const input = parseBody(WebviewCreateSchema, req.body);
const owner = ownerFor(req);
const user = getAuthUser(req);
const created = await withWebviews(async (list) => {
const mine = list.filter((w) => canAccessOwned(user, w.owner));
if (mine.length >= MAX_WEBVIEWS) return null;
const webview: Webview = {
id: randomUUID(),
name: input.name,
url: input.url,
icon: input.icon,
// Proxy is the safe default: it is the only mode that works for a plain-HTTP
// dashboard on an HTTPS Codeman, which is the common case.
embedMode: input.embedMode ?? 'proxy',
trusted: input.trusted ?? false,
owner,
createdAt: Date.now(),
};
list.push(webview);
await writeWebviews(configDir(), list);
return webview;
});
if (!created) {
return reply
.code(400)
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, `Webview limit reached (max ${MAX_WEBVIEWS})`));
}
ctx.broadcast(SseEvent.WebviewChanged, { action: 'created', id: created.id });
return { success: true, data: created };
});
app.patch<{ Params: { id: string } }>('/api/webviews/:id', async (req, reply) => {
const input = parseBody(WebviewUpdateSchema, req.body);
const user = getAuthUser(req);
const { id } = req.params;
const updated = await withWebviews(async (list) => {
const index = list.findIndex((w) => w.id === id);
if (index === -1) return 'not-found' as const;
if (!canAccessOwned(user, list[index].owner)) return 'forbidden' as const;
const next: Webview = { ...list[index], ...input };
list[index] = next;
await writeWebviews(configDir(), list);
return next;
});
if (updated === 'not-found') {
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Webview not found'));
}
if (updated === 'forbidden') {
return reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN, 'Not your webview'));
}
// Any edit invalidates the outstanding capability. Otherwise a token minted
// against the OLD url keeps proxying to it after the user repointed the tab.
webviewCapabilities.revokeWebview(id);
ctx.broadcast(SseEvent.WebviewChanged, { action: 'updated', id });
return { success: true, data: updated };
});
app.delete<{ Params: { id: string } }>('/api/webviews/:id', async (req, reply) => {
const user = getAuthUser(req);
const { id } = req.params;
const result = await withWebviews(async (list) => {
const index = list.findIndex((w) => w.id === id);
if (index === -1) return 'not-found' as const;
if (!canAccessOwned(user, list[index].owner)) return 'forbidden' as const;
list.splice(index, 1);
await writeWebviews(configDir(), list);
return 'deleted' as const;
});
if (result === 'not-found') {
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Webview not found'));
}
if (result === 'forbidden') {
return reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN, 'Not your webview'));
}
webviewCapabilities.revokeWebview(id);
socketCounts.delete(id);
ctx.broadcast(SseEvent.WebviewChanged, { action: 'deleted', id });
return { success: true, data: { id } };
});
/**
* Reachability + framing probe for the editor's "Test" button.
*
* Runs from the SERVER, which is the network position the proxy will use, so a
* green result here means the proxy will actually work. Never throws upstream
* failures at the caller: an unreachable dashboard is a normal answer, not a 500.
*/
app.post('/api/webviews/probe', async (req) => {
const { url } = parseBody(WebviewProbeSchema, req.body);
return { success: true, data: await probeUrl(url) };
});
/**
* Mint the capability the iframe will load. Separate from GET /api/webviews so a
* capability exists only for dashboards actually opened, and so the TTL clock
* starts on open rather than on page load.
*/
app.post<{ Params: { id: string } }>('/api/webviews/:id/open', async (req, reply) => {
const user = getAuthUser(req);
const { id } = req.params;
const webview = await withWebviews(async (list) => {
const index = list.findIndex((w) => w.id === id);
if (index === -1) return 'not-found' as const;
if (!canAccessOwned(user, list[index].owner)) return 'forbidden' as const;
list[index] = { ...list[index], lastOpenedAt: Date.now() };
await writeWebviews(configDir(), list);
return list[index];
});
if (webview === 'not-found') {
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Webview not found'));
}
if (webview === 'forbidden') {
return reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN, 'Not your webview'));
}
// Direct mode has no capability to mint: the iframe loads the real URL.
if (webview.embedMode === 'direct') {
const data: WebviewOpenData = { webview };
return { success: true, data };
}
const capability = webviewCapabilities.mint(webview.id, webview.owner);
const data: WebviewOpenData = { webview, embedUrl: proxyPrefixFor(capability) };
return { success: true, data };
});
}
async function probeUrl(url: string): Promise<WebviewProbe> {
const target = parseWebviewUrl(url);
if (!target) {
return {
reachable: false,
framable: false,
recommendedMode: 'proxy',
reason: 'Invalid URL',
};
}
try {
const response = await fetch(target.href, {
method: 'GET',
redirect: 'manual',
signal: AbortSignal.timeout(WEBVIEW_PROBE_TIMEOUT_MS),
});
// The body is irrelevant to the probe; release the socket rather than leak it.
await response.body?.cancel().catch(() => undefined);
const xFrameOptions = response.headers.get('x-frame-options') ?? undefined;
const csp = response.headers.get('content-security-policy') ?? undefined;
const frameAncestors = extractFrameAncestors(csp);
const framable = isFramableCrossOrigin(xFrameOptions, csp);
const isHttp = target.protocol === 'http:';
// Direct embedding is only viable for an HTTPS target that permits framing:
// an HTTPS Codeman page cannot embed http:// at all (mixed content).
const recommendedMode = !isHttp && framable ? 'direct' : 'proxy';
const reason = isHttp
? 'Plain HTTP: an HTTPS Codeman page cannot embed it directly, so it is proxied.'
: framable
? 'Reachable and allows framing: can be embedded directly.'
: 'Reachable but refuses framing, so it is proxied.';
return {
reachable: true,
status: response.status,
xFrameOptions,
frameAncestors,
framable,
recommendedMode,
reason,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
reachable: false,
framable: false,
recommendedMode: 'proxy',
reason: `Server could not reach it: ${message}`,
};
}
}
// ───────────────────────────── Proxy ─────────────────────────────
function registerProxyRoutes(app: FastifyInstance): void {
app.register(async (scope) => {
// Encapsulated to this plugin only. The proxy must relay request bodies
// BYTE-FOR-BYTE, so every parser is replaced with a pass-through that hands
// back the raw stream. Doing this on the root instance would break JSON
// routes and un-fix the text/plain CSRF hardening.
scope.removeAllContentTypeParsers();
scope.addContentTypeParser('*', (_req, payload, done) => done(null, payload));
// A single GET route serving both roles: `handler` for normal requests,
// `wsHandler` for upgrades. Registering them as two routes on one URL would
// collide.
scope.route<{ Params: ProxyParams }>({
method: 'GET',
url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`,
handler: proxyHttp,
wsHandler: proxyWebSocket,
});
// HEAD is deliberately absent: Fastify's `exposeHeadRoutes` already derives a
// HEAD route from the GET above, and declaring it again is a startup error.
scope.route<{ Params: ProxyParams }>({
method: ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`,
handler: proxyHttp,
});
// `/webview/<cap>` with no trailing slash: redirect rather than serve, so the
// browser's notion of the base path ends in `/` and relative URLs in the
// dashboard's HTML resolve inside the prefix instead of one level above it.
scope.get<{ Params: { cap: string } }>(`${WEBVIEW_PROXY_PREFIX}/:cap`, (req, reply) => {
return reply.redirect(proxyPrefixFor(req.params.cap), 302);
});
});
}
/** Resolve a capability to its live webview record, or null. */
async function lookupCapability(capability: string): Promise<Webview | null> {
const record = webviewCapabilities.resolve(capability);
if (!record) return null;
const list = await readWebviews(configDir());
const webview = list.find((w) => w.id === record.webviewId);
if (!webview) return null;
// The capability is bound to the identity that minted it; an ownership change
// on the record must not leave a stale token working.
if (webview.owner !== record.owner) return null;
return webview;
}
/**
* ⚠ Every exit path RETURNS `reply.send(...)`.
*
* This handler is `async`, and Fastify resolves an async handler's promise as the
* response. `reply.send(stream)` followed by a bare `return` resolves to
* `undefined` before the stream has been consumed, and Fastify then answers with
* an EMPTY body: HTML (a synchronously-set string payload) survives it, every
* streamed asset comes back zero-length. Returning the reply is what tells Fastify
* the response is already owned by this handler.
*/
function proxyHttp(req: FastifyRequest<{ Params: ProxyParams }>, reply: FastifyReply): Promise<FastifyReply> {
return proxyRequest(req, reply, req.params.cap, req.params['*'] ?? '');
}
/**
* Proxy one request to the dashboard behind `cap`, serving `wildcard` as the
* upstream path. Split out from the route handler so the 404 fallback (which has
* no route params) can reuse it.
*/
async function proxyRequest(
req: FastifyRequest,
reply: FastifyReply,
cap: string,
wildcard: string
): Promise<FastifyReply> {
const webview = await lookupCapability(cap);
if (!webview) {
return reply.code(403).type('text/plain').send('Forbidden: unknown or expired webview capability');
}
// CORS is required even though the URL is on this host: a sandboxed dashboard is
// opaque-origin, so its fetch/XHR are cross-origin requests. See
// buildProxyCorsHeaders.
const cors = buildProxyCorsHeaders(
typeof req.headers.origin === 'string' ? req.headers.origin : undefined,
typeof req.headers['access-control-request-headers'] === 'string'
? req.headers['access-control-request-headers']
: undefined
);
// Answer the preflight here rather than relaying it: the dashboard has no reason
// to know it is being framed, and most would reject an unexpected `Origin: null`.
if (req.method === 'OPTIONS' && req.headers['access-control-request-method']) {
for (const [key, value] of Object.entries(cors)) reply.header(key, value);
return reply.code(204).send();
}
const queryStart = req.url.indexOf('?');
const search = queryStart === -1 ? '' : req.url.slice(queryStart);
const upstream = resolveUpstreamUrl(webview.url, wildcard, search);
if (!upstream) {
return reply.code(400).type('text/plain').send('Bad Request: path escapes the dashboard origin');
}
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
const headers = buildUpstreamRequestHeaders(req.headers, upstream, {
forwardCookies: webview.trusted,
sessionCookieName: AUTH_COOKIE_NAME,
refererPath: typeof req.headers.referer === 'string' ? stripProxyPrefix(req.headers.referer, cap) : undefined,
});
let response: Response;
try {
response = await fetch(upstream.href, {
method: req.method,
headers,
body: hasBody ? (req.body as Readable) : undefined,
// Required by undici whenever the body is a stream.
...(hasBody ? { duplex: 'half' } : {}),
// Redirects are rewritten into the proxy prefix instead of followed, so the
// browser's URL stays inside the frame and relative assets keep resolving.
redirect: 'manual',
signal: AbortSignal.timeout(WEBVIEW_UPSTREAM_TIMEOUT_MS),
} as RequestInit);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return reply.code(502).type('text/plain').send(`Dashboard unreachable: ${message}`);
}
const secureContext = req.protocol === 'https';
const {
headers: outHeaders,
setCookie,
csp,
} = buildDownstreamResponseHeaders(
response.headers as unknown as Iterable<[string, string]>,
response.headers.getSetCookie(),
cap,
upstream,
secureContext
);
reply.code(response.status);
for (const [key, value] of Object.entries(outHeaders)) reply.header(key, value);
// After the upstream headers, so ours win: an upstream ACAO would name the
// dashboard's own origin, not the opaque origin this frame actually has.
for (const [key, value] of Object.entries(cors)) reply.header(key, value);
for (const cookie of setCookie) reply.header('set-cookie', cookie);
// registerSecurityHeaders already stamped Codeman's own `default-src 'self'`
// policy on this reply during onRequest. Left in place it breaks essentially
// every dashboard (inline scripts, CDN assets), so it is replaced by the
// upstream's own policy, or removed when the upstream had none.
if (csp) reply.header('content-security-policy', csp);
else reply.removeHeader('content-security-policy');
if (!response.body || req.method === 'HEAD') {
return reply.send();
}
const contentType = response.headers.get('content-type') ?? undefined;
const declaredLength = Number(response.headers.get('content-length') ?? '0');
const rewritable = isHtmlContentType(contentType) && declaredLength <= MAX_WEBVIEW_HTML_REWRITE_BYTES;
if (rewritable) {
// Buffer only HTML, only under the cap: `<base>` injection needs the whole
// document, and buffering an unbounded upstream body is a memory hazard.
const html = await response.text();
return reply.send(html.length <= MAX_WEBVIEW_HTML_REWRITE_BYTES ? rewriteHtml(html, cap) : html);
}
return reply.send(Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]));
}
/**
* Last-resort handler for a dashboard asset requested with a ROOT-ABSOLUTE URL.
*
* `<base href>` fixes relative URLs and the HTML rewrite fixes `src`/`href`/`action`
* attributes, but neither can reach a URL built at runtime: `fetch('/api/data')`,
* `import('/chunk.js')`, `url(/img.png)` inside a stylesheet. Those arrive at
* Codeman's root and 404.
*
* The `Referer` identifies which dashboard asked, so the request can be routed to
* the right upstream. Wiring it into the 404 handler rather than a catch-all route
* is what keeps it contained: every real Codeman route matches first, and this only
* ever sees requests that were going to fail anyway.
*
* @returns true when the request was handled (caller must not also reply).
*/
export async function tryWebviewRefererFallback(req: FastifyRequest, reply: FastifyReply): Promise<boolean> {
// Safe methods only. A write arriving here has already lost its raw body to the
// root instance's JSON parser, so it could not be relayed faithfully anyway.
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
const capability = capabilityFromReferer(typeof req.headers.referer === 'string' ? req.headers.referer : undefined);
if (!capability) return false;
if (!webviewCapabilities.resolve(capability)) return false;
const path = req.url.split('?')[0].replace(/^\//, '');
await proxyRequest(req, reply, capability, path);
return true;
}
/** Turn a proxy-side Referer back into the upstream path it corresponds to. */
function stripProxyPrefix(referer: string, capability: string): string | undefined {
try {
const url = new URL(referer);
const prefix = proxyPrefixFor(capability);
if (!url.pathname.startsWith(prefix)) return undefined;
return `/${url.pathname.slice(prefix.length)}${url.search}`;
} catch {
return undefined;
}
}
// ─────────────────────────── WebSocket ───────────────────────────
/**
* Relay a WebSocket through to the dashboard.
*
* Live dashboards (Grafana, Home Assistant, Uptime Kuma) push over WebSocket, so
* without this leg they load but their realtime panels stay permanently empty.
*
* The upgrade is guarded on the capability, NOT on `Origin`: a sandboxed iframe is
* opaque-origin, so its upgrade arrives with `Origin: null`. The host allowlist
* still applies (it runs in the global onRequest hook), so DNS-rebinding
* protection is unaffected.
*/
function proxyWebSocket(socket: WebSocket, req: FastifyRequest<{ Params: ProxyParams }>): void {
const { cap } = req.params;
void (async () => {
const webview = await lookupCapability(cap);
if (!webview) {
socket.close(4003, 'Forbidden');
return;
}
const live = socketCounts.get(webview.id) ?? 0;
if (live >= MAX_WEBVIEW_SOCKETS) {
socket.close(4008, 'Too many connections');
return;
}
const wildcard = req.params['*'] ?? '';
const queryStart = req.url.indexOf('?');
const search = queryStart === -1 ? '' : req.url.slice(queryStart);
const upstream = resolveUpstreamUrl(webview.url, wildcard, search);
if (!upstream) {
socket.close(4003, 'Forbidden');
return;
}
socketCounts.set(webview.id, live + 1);
let released = false;
const release = () => {
if (released) return;
released = true;
const count = socketCounts.get(webview.id) ?? 1;
if (count <= 1) socketCounts.delete(webview.id);
else socketCounts.set(webview.id, count - 1);
};
const protocols = req.headers['sec-websocket-protocol'];
const upstreamSocket = new WsClient(
upstreamWebSocketUrl(upstream),
protocols ? String(protocols).split(/,\s*/) : [],
{
headers: {
origin: upstream.origin,
...(webview.trusted && req.headers.cookie ? { cookie: String(req.headers.cookie) } : {}),
},
handshakeTimeout: WEBVIEW_UPSTREAM_TIMEOUT_MS,
}
);
// Buffer anything the browser sends before the upstream handshake completes,
// rather than dropping it: a client that sends a subscribe frame immediately
// would otherwise sit connected and silent forever.
const pending: Array<Buffer | string> = [];
let upstreamOpen = false;
upstreamSocket.on('open', () => {
upstreamOpen = true;
for (const message of pending) upstreamSocket.send(message);
pending.length = 0;
});
socket.on('message', (data: Buffer, isBinary: boolean) => {
const payload = isBinary ? data : data.toString();
if (upstreamOpen) upstreamSocket.send(payload);
else if (pending.length < 64) pending.push(payload);
});
upstreamSocket.on('message', (data: Buffer, isBinary: boolean) => {
if (socket.readyState === socket.OPEN) socket.send(isBinary ? data : data.toString());
});
// Paired close in both directions, so neither side is left half-open.
const closeBoth = (code?: number, reason?: string) => {
release();
// Codes outside 3000-4999 (and 1000/1001) are not valid to send onward.
const safeCode = code && code >= 3000 && code <= 4999 ? code : 1000;
if (socket.readyState === socket.OPEN) socket.close(safeCode, reason);
if (upstreamSocket.readyState === WsClient.OPEN || upstreamSocket.readyState === WsClient.CONNECTING) {
upstreamSocket.close(safeCode, reason);
}
};
socket.on('close', (code: number, reason: Buffer) => closeBoth(code, reason?.toString()));
upstreamSocket.on('close', (code: number, reason: Buffer) => closeBoth(code, reason?.toString()));
socket.on('error', () => closeBoth());
upstreamSocket.on('error', () => {
release();
if (socket.readyState === socket.OPEN) socket.close(1011, 'Upstream error');
});
})();
}
+73
View File
@@ -9,6 +9,7 @@
import { z } from 'zod';
import { SAFE_PATH_PATTERN, isSafePushEndpoint } from '../utils/index.js';
import { isValidWebviewUrl } from './webview-proxy.js';
import {
MAX_TERMINAL_BUFFER_BYTES,
MAX_TERMINAL_SCROLLBACK_LINES,
@@ -49,6 +50,39 @@ const safePathSchema = z.string().max(1000).refine(isValidWorkingDir, {
message: 'Invalid path: must be absolute, no shell metacharacters or traversal',
});
/**
* Filesystem picker paths are never interpolated into a shell command, so legal
* filename characters such as spaces, quotes, and parentheses are accepted.
* Containment and symlink resolution are enforced by the route after parsing.
*/
const filesystemPickerPathSchema = z
.string()
.max(4096)
.refine((p) => p.startsWith('/') && !p.includes('\0') && !p.includes('\n') && !p.includes('\r'), {
message: 'Path must be an absolute filesystem path',
})
.refine((p) => !p.split('/').includes('..'), { message: 'Path traversal is not allowed' });
/** Query validation for the lazy, allowlisted filesystem path picker. */
export const FilesystemBrowseQuerySchema = z.object({
path: filesystemPickerPathSchema.optional(),
sessionId: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
.optional(),
});
/** Query validation for a single allowlisted path-picker file preview. */
export const FilesystemPreviewQuerySchema = z.object({
path: filesystemPickerPathSchema,
sessionId: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
.optional(),
});
// ========== Env Var Allowlist ==========
/** Allowlisted env var key prefixes */
@@ -1184,3 +1218,42 @@ export const SearchQuerySchema = z.object({
),
limit: z.coerce.number().int().min(1).max(60).optional(),
});
// ========== Web Tabs (dashboard URLs) ==========
/**
* A dashboard URL. `isValidWebviewUrl` rejects anything that is not plain
* http/https, anything carrying embedded credentials, and anything without a
* hostname. See `src/web/webview-proxy.ts` for why each of those matters.
*/
const webviewUrlSchema = z
.string()
.trim()
.min(1, 'URL is required')
.max(2000, 'URL too long (max 2000 chars)')
.refine(isValidWebviewUrl, {
message: 'Invalid URL: must be http(s), with a hostname and no embedded credentials',
});
const WebviewBaseSchema = z.object({
name: z.string().trim().min(1, 'Name is required').max(60, 'Name too long (max 60 chars)'),
url: webviewUrlSchema,
/** A single glyph shown on the tab. Bounded generously: one emoji can be several code units. */
icon: z.string().max(8).optional(),
embedMode: z.enum(['proxy', 'direct']).optional(),
/**
* Opt out of the iframe sandbox. Defaults to false: a proxied page is served
* from Codeman's own origin, so `allow-same-origin` would let it read this page
* and call the API that spawns agents.
*/
trusted: z.boolean().optional(),
});
/** POST /api/webviews */
export const WebviewCreateSchema = WebviewBaseSchema;
/** PATCH /api/webviews/:id, partial update. */
export const WebviewUpdateSchema = WebviewBaseSchema.partial();
/** POST /api/webviews/probe: reachability + framing check for the editor's Test button. */
export const WebviewProbeSchema = z.object({ url: webviewUrlSchema });
+11 -4
View File
@@ -160,6 +160,8 @@ import {
registerMeRoutes,
registerAdminRoutes,
registerWsRoutes,
registerWebviewRoutes,
tryWebviewRefererFallback,
} from './routes/index.js';
import { CronService } from '../cron/cron-service.js';
@@ -851,13 +853,17 @@ export class WebServer extends EventEmitter {
// Stable-contract 404 for unknown /api routes — without this, Fastify's
// default not-found payload {message,error,statusCode} would be wrapped by
// the envelope hook into a contradictory HTTP 404 {success:true,...}.
this.app.setNotFoundHandler((req, reply) => {
this.app.setNotFoundHandler(async (req, reply) => {
const notFound = `Route ${req.method}:${req.url} not found`;
if (req.url.startsWith('/api')) {
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, notFound));
return;
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, notFound));
}
reply.code(404).send({ message: notFound, error: 'Not Found', statusCode: 404 });
// A web-tab dashboard asking for a root-absolute asset (`fetch('/api/data')`,
// `import('/chunk.js')`) lands here, because `<base href>` cannot rewrite a URL
// built at runtime. Its Referer says which dashboard to relay to. Deliberately
// placed on the 404 path so every real Codeman route still wins.
if (await tryWebviewRefererFallback(req, reply)) return reply;
return reply.code(404).send({ message: notFound, error: 'Not Found', statusCode: 404 });
});
// Crash diagnostics beacon — frontend POSTs breadcrumbs, GET to read them.
@@ -925,6 +931,7 @@ export class WebServer extends EventEmitter {
registerMeRoutes(this.app, ctx);
registerAdminRoutes(this.app, ctx);
registerOrchestratorRoutes(this.app, ctx);
registerWebviewRoutes(this.app, ctx);
// Cron: build the service from the same context, recompute
// due times for any persisted jobs, then expose it to its routes.
+10 -1
View File
@@ -5,7 +5,7 @@
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
* Both files MUST be kept in sync.
*
* 148 event constants organized by category:
* 149 event constants organized by category:
* - **Core** (1): init
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
* - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ...
@@ -30,6 +30,7 @@
* - **Cases** (4): created, linked, deleted, order-changed
* - **Docker cases** (8): exportComplete/Failed, importComplete, imageBuild*, containerRecreated
* - **Multi-user** (3): admin:usersChanged, auth:passwordChangeRequired, session:orderChanged
* - **Web tabs** (1): webview:changed
*
* Naming convention: `domain:action` (e.g., `session:created`, `respawn:stateChanged`)
*
@@ -413,6 +414,11 @@ export const AuthPasswordChangeRequired = 'auth:passwordChangeRequired' as const
/** Global session tab order changed (synced across devices). COD-131. */
export const SessionOrderChanged = 'session:orderChanged' as const;
/** A saved web tab (dashboard URL) was created, updated or deleted.
* Payload: `{ action: 'created' | 'updated' | 'deleted', id }`. The client
* re-fetches the list rather than patching from the payload. */
export const WebviewChanged = 'webview:changed' as const;
// ─── Namespace Re-export ─────────────────────────────────────────────────────
/**
@@ -615,4 +621,7 @@ export const SseEvent = {
// Session order (global tab order sync)
SessionOrderChanged,
// Web tabs (dashboard URLs)
WebviewChanged,
} as const;
+507
View File
@@ -0,0 +1,507 @@
/**
* @fileoverview Pure helpers for the web-tab reverse proxy. No I/O, no Fastify.
*
* The proxy exists because an iframe pointing straight at a dashboard cannot work
* in the deployment that matters: prod serves HTTPS (behind `tailscale serve`), so
* a plain-HTTP dashboard is hard-blocked as mixed content; many dashboards also
* refuse framing outright via `X-Frame-Options` / `frame-ancestors`; and Codeman's
* own CSP (`default-src 'self'`) blocks cross-origin frames anyway. Serving the
* dashboard through Codeman's own origin dissolves all three at once, and keeps
* the production CSP byte-for-byte unchanged because `/webview/...` is `'self'`.
*
* ## Origin-scoped, not path-scoped
*
* `/webview/<cap>/x/y` always maps to `<upstream origin>/x/y`, never to
* `<upstream origin><saved path>/x/y`. Dashboards reference assets with
* root-absolute paths (`/public/build/app.js`), so origin-scoping is the only
* mapping under which those resolve. The saved URL's own path+query is used for
* exactly one thing: what `/webview/<cap>/` itself serves (the landing page).
*
* ## What gets rewritten, and why each one is load-bearing
*
* - `x-frame-options` / CSP `frame-ancestors`: dropped, else the browser refuses
* to render the frame. This is the whole point of the proxy.
* - `content-encoding` / `content-length`: dropped, because undici's `fetch`
* already decoded the body. Forwarding them makes the browser try to gunzip
* plaintext.
* - `authorization` + the `codeman_session` cookie: stripped on the way OUT. In
* trusted mode the iframe is same-origin, so the browser attaches Codeman's own
* Basic-auth header and session cookie to every proxied request. Forwarding
* those would hand CODEMAN_PASSWORD to the dashboard.
* - `Location` and `Set-Cookie`: remapped into the proxy path, else a redirect or
* a login cookie escapes the prefix and lands on Codeman's root.
* - `<base href>` + root-absolute attribute rewriting: relative and `/`-rooted
* URLs in the HTML resolve back through the proxy instead of hitting Codeman.
*
* No `X-Forwarded-*` is sent deliberately: apps that honor it generate absolute
* URLs against Codeman's root, which would bypass the `/webview/<cap>/` prefix
* that everything else here works to preserve.
*/
import { WEBVIEW_PROXY_PREFIX } from '../config/webview-limits.js';
/** Headers that are per-connection and must never be relayed in either direction. */
const HOP_BY_HOP = new Set([
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'proxy-connection',
'te',
'trailer',
'transfer-encoding',
'upgrade',
]);
/**
* Request headers dropped on the way to the upstream. `authorization` and `cookie`
* carry Codeman's own credentials on a same-origin (trusted) frame; `host`,
* `content-length` and `accept-encoding` are recomputed by undici.
*/
const DROP_REQUEST_HEADERS = new Set([
...HOP_BY_HOP,
'host',
'content-length',
'accept-encoding',
'authorization',
'cookie',
'origin',
'referer',
'x-codeman-hook-secret',
]);
/** Response headers dropped on the way back to the browser. */
const DROP_RESPONSE_HEADERS = new Set([
...HOP_BY_HOP,
'content-encoding',
'content-length',
'x-frame-options',
'content-security-policy-report-only',
// Cross-origin isolation headers describe the UPSTREAM's origin policy; applied
// to a frame on Codeman's origin they only produce blocked-resource surprises.
'cross-origin-opener-policy',
'cross-origin-embedder-policy',
'cross-origin-resource-policy',
'set-cookie',
'location',
'content-security-policy',
// The upstream's CORS answer describes ITS origin; the frame asking is
// opaque-origin on ours, so ours must replace it (see buildProxyCorsHeaders).
'access-control-allow-origin',
'access-control-allow-credentials',
'access-control-allow-methods',
'access-control-allow-headers',
'access-control-expose-headers',
'access-control-max-age',
]);
/** The same-origin path prefix an iframe loads for a given capability. */
export function proxyPrefixFor(capability: string): string {
return `${WEBVIEW_PROXY_PREFIX}/${capability}/`;
}
/**
* Parse and validate a user-supplied dashboard URL.
*
* Rejects everything that is not plain `http:`/`https:`, anything carrying
* embedded credentials (they would be silently forwarded and logged), and
* anything without a hostname. Returns the normalized `URL` or null.
*/
export function parseWebviewUrl(raw: string): URL | null {
if (typeof raw !== 'string' || raw.trim() === '') return null;
let url: URL;
try {
url = new URL(raw.trim());
} catch {
return null;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
if (url.username !== '' || url.password !== '') return null;
if (!url.hostname) return null;
return url;
}
/** Convenience predicate for Zod refinements. */
export function isValidWebviewUrl(raw: string): boolean {
return parseWebviewUrl(raw) !== null;
}
/**
* Map a proxy request path to its upstream URL.
*
* `wildcard` is Fastify's `*` param: the path after `/webview/<cap>/`, without a
* leading slash. An empty wildcard means the landing page, which is the saved
* URL's own path and query.
*
* Returns null when the result would escape the upstream origin (a `..` chain, a
* protocol-relative `//evil.com` wildcard, or an absolute URL smuggled into the
* path). That check is what keeps this from being an open proxy.
*/
export function resolveUpstreamUrl(savedUrl: string, wildcard: string, search: string): URL | null {
const base = parseWebviewUrl(savedUrl);
if (!base) return null;
if (wildcard === '' || wildcard === '/') {
const landing = new URL(base.pathname + (search || base.search), base.origin);
return landing.origin === base.origin ? landing : null;
}
// A wildcard starting with `//` would parse as protocol-relative and jump host.
const path = wildcard.startsWith('/') ? wildcard : `/${wildcard}`;
if (path.startsWith('//')) return null;
let target: URL;
try {
target = new URL(path + (search || ''), base.origin);
} catch {
return null;
}
return target.origin === base.origin ? target : null;
}
/** Extract the capability from a `/webview/<cap>/...` pathname, or null. */
export function capabilityFromProxyPath(pathname: string): string | null {
if (typeof pathname !== 'string') return null;
const prefix = `${WEBVIEW_PROXY_PREFIX}/`;
if (!pathname.startsWith(prefix)) return null;
const rest = pathname.slice(prefix.length);
const slash = rest.indexOf('/');
const cap = slash === -1 ? rest : rest.slice(0, slash);
return /^[A-Za-z0-9_-]{16,128}$/.test(cap) ? cap : null;
}
/**
* Extract the capability a `Referer` belongs to. Backs the 404 fallback that
* catches root-absolute asset requests (`/static/app.js`) which `<base>` cannot fix.
*/
export function capabilityFromReferer(referer: string | undefined): string | null {
if (!referer) return null;
try {
return capabilityFromProxyPath(new URL(referer).pathname);
} catch {
return null;
}
}
/** Remove the `frame-ancestors` directive from a CSP, preserving the rest. */
export function stripFrameAncestors(csp: string): string {
return csp
.split(';')
.map((d) => d.trim())
.filter((d) => d !== '' && !/^frame-ancestors\b/i.test(d))
.join('; ');
}
/** The `frame-ancestors` directive value from a CSP, or undefined. */
export function extractFrameAncestors(csp: string | undefined): string | undefined {
if (!csp) return undefined;
for (const directive of csp.split(';')) {
const trimmed = directive.trim();
if (/^frame-ancestors\b/i.test(trimmed)) {
return trimmed.slice('frame-ancestors'.length).trim();
}
}
return undefined;
}
/**
* Whether a target permits being framed by a different origin, judged from its
* `X-Frame-Options` and CSP. Used only to recommend proxy vs direct mode in the
* editor; the proxy path works either way.
*/
export function isFramableCrossOrigin(xFrameOptions: string | undefined, csp: string | undefined): boolean {
const xfo = xFrameOptions?.trim().toLowerCase();
if (xfo === 'deny' || xfo === 'sameorigin') return false;
const ancestors = extractFrameAncestors(csp)?.toLowerCase();
if (ancestors === undefined) return true;
if (ancestors.includes("'none'")) return false;
// 'self' alone means same-origin only, which a cross-origin embed is not.
if (ancestors === "'self'") return false;
return ancestors.includes('*') || ancestors.includes('http');
}
/**
* Rewrite an upstream `Location` into the proxy path.
*
* Same-origin redirects (relative or absolute) are remapped so the browser stays
* inside the frame. Cross-origin redirects are returned unchanged rather than
* proxied: relaying them would turn this into an open proxy for any host the
* upstream chooses to name.
*/
export function rewriteLocation(location: string, requestUrl: URL, capability: string): string {
let resolved: URL;
try {
resolved = new URL(location, requestUrl);
} catch {
return location;
}
if (resolved.origin !== requestUrl.origin) return location;
const suffix = resolved.pathname.replace(/^\//, '');
return `${proxyPrefixFor(capability)}${suffix}${resolved.search}${resolved.hash}`;
}
/**
* Rewrite an upstream `Set-Cookie` so it applies to the proxy path only.
*
* `Domain` is dropped (the cookie now belongs to Codeman's host), `Path` is
* rebased onto the proxy prefix so two dashboards cannot collide on a shared
* cookie name, and `Secure` is dropped when Codeman itself is serving plain HTTP
* in dev, where a Secure cookie would simply be discarded.
*/
export function rewriteSetCookie(cookie: string, capability: string, secureContext: boolean): string {
const parts = cookie.split(';');
const out: string[] = [parts[0]];
let sawPath = false;
for (const raw of parts.slice(1)) {
const attr = raw.trim();
const lower = attr.toLowerCase();
if (lower.startsWith('domain=')) continue;
if (lower === 'secure' && !secureContext) continue;
if (lower.startsWith('path=')) {
sawPath = true;
const value = attr.slice('path='.length);
const suffix = value.replace(/^\//, '');
out.push(`Path=${proxyPrefixFor(capability)}${suffix}`);
continue;
}
out.push(attr);
}
if (!sawPath) out.push(`Path=${proxyPrefixFor(capability)}`);
return out.join('; ');
}
/** Drop named cookies from a `Cookie` request header, returning undefined if none remain. */
export function filterCookieHeader(cookie: string | undefined, drop: string[]): string | undefined {
if (!cookie) return undefined;
const dropSet = new Set(drop.map((n) => n.toLowerCase()));
const kept = cookie
.split(';')
.map((c) => c.trim())
.filter((c) => c !== '' && !dropSet.has(c.slice(0, c.indexOf('=')).trim().toLowerCase()));
return kept.length > 0 ? kept.join('; ') : undefined;
}
/** Build the header set sent upstream, from the browser's request headers. */
export function buildUpstreamRequestHeaders(
incoming: Record<string, string | string[] | undefined>,
upstream: URL,
opts: { forwardCookies: boolean; sessionCookieName: string; refererPath?: string }
): Record<string, string> {
const headers: Record<string, string> = {};
for (const [key, value] of Object.entries(incoming)) {
const lower = key.toLowerCase();
if (DROP_REQUEST_HEADERS.has(lower)) continue;
if (value === undefined) continue;
headers[lower] = Array.isArray(value) ? value.join(', ') : value;
}
if (opts.forwardCookies) {
const raw = incoming['cookie'];
const cookie = filterCookieHeader(Array.isArray(raw) ? raw.join('; ') : raw, [opts.sessionCookieName]);
if (cookie) headers['cookie'] = cookie;
}
// Present as if the browser were talking to the dashboard directly. Apps that
// check Origin on writes (CSRF defenses) need this to match their own origin.
headers['origin'] = upstream.origin;
headers['referer'] = opts.refererPath ? new URL(opts.refererPath, upstream.origin).href : upstream.href;
return headers;
}
/**
* Build the response headers sent to the browser.
*
* Also returns the CSP to apply: the upstream's, minus `frame-ancestors`. Callers
* MUST set (or explicitly clear) this, because `registerSecurityHeaders` has
* already stamped Codeman's own `default-src 'self'` policy onto the reply, and
* that policy would break virtually every dashboard.
*/
export function buildDownstreamResponseHeaders(
upstreamHeaders: Iterable<[string, string]>,
/**
* Upstream `Set-Cookie` values, already separated. Passed in rather than read
* from `upstreamHeaders` because iterating a `Headers` object JOINS duplicate
* set-cookie values into one comma-separated string, which cannot be split back
* apart reliably (Expires dates contain commas). Callers use
* `response.headers.getSetCookie()`.
*/
setCookies: string[],
capability: string,
requestUrl: URL,
secureContext: boolean
): { headers: Record<string, string>; setCookie: string[]; csp: string | null } {
const headers: Record<string, string> = {};
let csp: string | null = null;
for (const [key, value] of upstreamHeaders) {
const lower = key.toLowerCase();
if (lower === 'content-security-policy') {
const stripped = stripFrameAncestors(value);
csp = stripped === '' ? null : stripped;
continue;
}
if (lower === 'location') {
headers['location'] = rewriteLocation(value, requestUrl, capability);
continue;
}
if (DROP_RESPONSE_HEADERS.has(lower)) continue;
headers[lower] = value;
}
const setCookie = setCookies.map((cookie) => rewriteSetCookie(cookie, capability, secureContext));
return { headers, setCookie, csp };
}
/**
* A tiny script injected at the top of every proxied document, rewriting
* ROOT-ABSOLUTE URLs built at runtime so they stay inside the proxy prefix.
*
* `<base href>` only governs URLs the HTML parser resolves. A dashboard that calls
* `fetch('/api/data')` bypasses it entirely and the request lands on Codeman's own
* root, where it 404s. That is not a rare shape: it is how most dashboards talk to
* their own backend, and it presents as the dashboard's own "Failed to fetch".
*
* The `Referer`-keyed 404 fallback catches some of these, but deliberately NOT
* paths under `/api`, `/ws` or `/q` (widening it there would let a request-supplied
* header skip auth on Codeman's own API). Rewriting inside the iframe removes the
* whole class instead of trading security for it: the page never emits a
* root-absolute request in the first place.
*
* Runs before any page script because it is injected immediately after `<base>`.
* Only same-origin, non-prefixed, root-absolute URLs are touched; relative URLs
* (already handled by `<base>`) and cross-origin URLs are passed through.
*/
export function runtimeUrlShim(prefix: string): string {
// Kept dependency-free and defensive: it runs inside a page we do not control,
// and a throw here would break the dashboard rather than fix it.
return `<script>(function(){try{
var P=${JSON.stringify(prefix)};
function rw(u){
try{
if(u==null)return u;
if(typeof u!=='string'){
if(typeof URL!=='undefined'&&u instanceof URL)return rw(u.href);
return u;
}
if(u.indexOf(P)===0)return u;
if(u.charAt(0)==='/'&&u.charAt(1)!=='/')return P+u.slice(1);
if(/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(u)||u.indexOf('//')===0){
var a=new URL(u,location.href);
if(a.host===location.host&&a.pathname.indexOf(P)!==0){
a.pathname=P+a.pathname.replace(/^\\//,'');
return a.href;
}
}
return u;
}catch(e){return u;}
}
var of=window.fetch;
if(of)window.fetch=function(i,o){
try{
if(typeof Request!=='undefined'&&i instanceof Request)return of.call(this,new Request(rw(i.url),i),o);
return of.call(this,rw(i),o);
}catch(e){return of.call(this,i,o);}
};
if(window.XMLHttpRequest&&XMLHttpRequest.prototype.open){
var oo=XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open=function(m,u){
var a=[].slice.call(arguments);a[1]=rw(u);return oo.apply(this,a);
};
}
['WebSocket','EventSource'].forEach(function(k){
var C=window[k];if(!C)return;
function W(u,p){return p===undefined?new C(rw(u)):new C(rw(u),p);}
W.prototype=C.prototype;
['CONNECTING','OPEN','CLOSING','CLOSED'].forEach(function(s){if(s in C)W[s]=C[s];});
window[k]=W;
});
}catch(e){}})();</script>`;
}
/**
* Inject `<base href="/webview/<cap>/">` plus the runtime URL shim, and rebase
* root-absolute `src`/`href`/`action` attributes, which `<base>` alone does not
* affect.
*
* Three layers, because no single one is sufficient: `<base>` for parser-resolved
* relative URLs, attribute rewriting for root-absolute markup, and the shim for
* URLs built at runtime.
*/
export function rewriteHtml(html: string, capability: string): string {
const prefix = proxyPrefixFor(capability);
// Fresh regexes per call: module-level /g patterns carry `lastIndex` between calls.
const rebased = html
.replace(/(\s(?:src|href|action)\s*=\s*")\/(?!\/)/gi, `$1${prefix}`)
.replace(/(\s(?:src|href|action)\s*=\s*')\/(?!\/)/gi, `$1${prefix}`);
// A page that ships its own <base> keeps it (overriding it would break the
// author's intent), but it STILL needs the shim, which is the layer that
// catches runtime-built URLs. So only the base tag is conditional.
const injected = (/<base\b/i.test(rebased) ? '' : `<base href="${prefix}">`) + runtimeUrlShim(prefix);
const headMatch = /<head\b[^>]*>/i.exec(rebased);
if (headMatch) {
const at = headMatch.index + headMatch[0].length;
return rebased.slice(0, at) + injected + rebased.slice(at);
}
const htmlMatch = /<html\b[^>]*>/i.exec(rebased);
if (htmlMatch) {
const at = htmlMatch.index + htmlMatch[0].length;
return rebased.slice(0, at) + injected + rebased.slice(at);
}
return injected + rebased;
}
/**
* CORS headers for a proxied response.
*
* Non-obvious but load-bearing: a SANDBOXED iframe (no `allow-same-origin`) runs
* in an OPAQUE origin, so every `fetch`/XHR it makes is a cross-origin request even
* though the URL is on this very host, and the browser requires CORS headers to
* hand back the response. Without this, a dashboard renders fine (script/css/img
* loads are not CORS-checked) while every one of its API calls fails with an opaque
* `net::ERR_FAILED` and the page shows its own "failed to load" state. `curl`
* cannot reproduce it, because curl does not enforce CORS.
*
* The origin is echoed rather than `*` so credentialed requests still work in
* trusted mode. `null` (the opaque-origin case) is echoed as-is, but WITHOUT
* `allow-credentials`, which browsers reject in combination.
*
* This grants nothing extra: the URL is already gated by the capability, and only
* a document that was handed the capability can construct these requests.
*/
export function buildProxyCorsHeaders(origin: string | undefined, requestedHeaders?: string): Record<string, string> {
if (!origin) return {};
const headers: Record<string, string> = {
'access-control-allow-origin': origin,
'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS',
'access-control-allow-headers': requestedHeaders && requestedHeaders.trim() !== '' ? requestedHeaders : '*',
'access-control-expose-headers': '*',
'access-control-max-age': '600',
vary: 'Origin',
};
// `Access-Control-Allow-Credentials: true` alongside a `null` origin is rejected
// by browsers; a sandboxed frame sends no credentials anyway.
if (origin !== 'null' && origin !== '*') headers['access-control-allow-credentials'] = 'true';
return headers;
}
/** Whether a content-type identifies HTML worth rewriting. */
export function isHtmlContentType(contentType: string | undefined): boolean {
if (!contentType) return false;
const type = contentType.split(';')[0].trim().toLowerCase();
return type === 'text/html' || type === 'application/xhtml+xml';
}
/** Map an upstream http(s) URL to its ws(s) equivalent for the WebSocket leg. */
export function upstreamWebSocketUrl(target: URL): string {
const ws = new URL(target.href);
ws.protocol = ws.protocol === 'https:' ? 'wss:' : 'ws:';
return ws.href;
}
+116
View File
@@ -0,0 +1,116 @@
/**
* @fileoverview Capability tokens for the web-tab proxy.
*
* The proxy cannot authenticate on Codeman's session cookie. A sandboxed iframe
* (no `allow-same-origin`) runs in an OPAQUE origin, so every request it makes is
* cross-site: the `SameSite=lax` `codeman_session` cookie is not sent, and its
* non-GET requests and WebSocket upgrades arrive with `Origin: null`, which the
* host guard rejects by design.
*
* So `/webview/:cap/*` authenticates on an unguessable capability minted by an
* already-authenticated `POST /api/webviews/:id/open`. Properties that make this
* safe to exempt from the cookie/Origin checks:
*
* - 128 bits of `randomBytes` entropy, base64url, never derived from anything.
* - Held in memory only. A restart invalidates every outstanding capability.
* - Rolling TTL: refreshed on use, expired after inactivity.
* - Bound to the minting user, so multi-user ownership survives the exemption.
* - Grants exactly one thing: relaying bytes to that one saved URL. It reaches no
* session, no file, no API surface.
*/
import { randomBytes } from 'node:crypto';
import { StaleExpirationMap } from './utils/index.js';
import { MAX_WEBVIEW_CAPABILITIES, WEBVIEW_CAPABILITY_TTL_MS } from './config/webview-limits.js';
export interface WebviewCapabilityRecord {
webviewId: string;
/** Username that minted it (multi-user); undefined in single-user mode. */
owner?: string;
createdAt: number;
}
export class WebviewCapabilityStore {
private readonly capabilities: StaleExpirationMap<string, WebviewCapabilityRecord>;
/** Reverse index so re-opening a webview reuses its capability instead of leaking one per click. */
private readonly byWebview = new Map<string, string>();
constructor(ttlMs: number = WEBVIEW_CAPABILITY_TTL_MS) {
this.capabilities = new StaleExpirationMap<string, WebviewCapabilityRecord>({
ttlMs,
refreshOnGet: true,
onExpire: (_token, record) => {
const current = this.byWebview.get(record.webviewId);
if (current !== undefined) this.byWebview.delete(record.webviewId);
},
});
}
/** Mint (or reuse) a capability for a webview. Returns the token. */
mint(webviewId: string, owner?: string): string {
const existing = this.byWebview.get(webviewId);
if (existing) {
const record = this.capabilities.get(existing);
// Reuse only while the record is live AND still belongs to the same identity.
if (record && record.owner === owner) return existing;
this.capabilities.delete(existing);
this.byWebview.delete(webviewId);
}
// Bound growth: a client that never reuses tokens must not grow this forever.
if (this.capabilities.size >= MAX_WEBVIEW_CAPABILITIES) this.capabilities.cleanup();
const token = randomBytes(24).toString('base64url');
this.capabilities.set(token, { webviewId, owner, createdAt: Date.now() });
this.byWebview.set(webviewId, token);
return token;
}
/** Resolve a capability, refreshing its TTL. Returns undefined when unknown or expired. */
resolve(token: string): WebviewCapabilityRecord | undefined {
if (!token) return undefined;
return this.capabilities.get(token);
}
/** Revoke every capability for a webview (called on delete/edit). */
revokeWebview(webviewId: string): void {
const token = this.byWebview.get(webviewId);
if (token) {
this.capabilities.delete(token);
this.byWebview.delete(webviewId);
}
}
/** Revoke every capability minted by a user (called on logout / user deletion). */
revokeOwner(owner: string): void {
for (const [webviewId, token] of [...this.byWebview]) {
const record = this.capabilities.peek(token);
if (record?.owner === owner) {
this.capabilities.delete(token);
this.byWebview.delete(webviewId);
}
}
}
get size(): number {
return this.capabilities.size;
}
dispose(): void {
this.capabilities.dispose();
this.byWebview.clear();
}
}
/**
* Process-wide capability store.
*
* A singleton rather than an injected dependency because two unrelated layers must
* agree on it: the proxy routes that mint and consume capabilities, and the auth
* middleware, which has to recognize a valid capability to know that a
* `/webview/...` request is legitimately exempt from the cookie and Origin checks.
* Threading a store through the auth middleware's construction just to answer that
* one question would be worse. The map's cleanup timer is `unref`'d, so holding
* this at module scope does not keep the process alive.
*/
export const webviewCapabilities = new WebviewCapabilityStore();
+37
View File
@@ -0,0 +1,37 @@
/**
* @fileoverview Persistence for web tabs (saved dashboard URLs).
*
* Stores `Webview` records in `~/.codeman/webviews.json`, following the same
* read-array / write-array shape as `src/remote-hosts.ts`. Deliberately dumb: no
* caching, no watchers. The list is small (bounded by MAX_WEBVIEWS) and is read
* on demand by the route handlers.
*
* The file lives under the instance data dir, so a beta instance started with a
* distinct CODEMAN_INSTANCE keeps its own dashboards.
*/
import { existsSync, mkdirSync } from 'node:fs';
import fs from 'node:fs/promises';
import { join } from 'node:path';
import type { Webview } from './types.js';
const WEBVIEWS_FILE = 'webviews.json';
export function webviewsPath(configDir: string): string {
return join(configDir, WEBVIEWS_FILE);
}
export async function readWebviews(configDir: string): Promise<Webview[]> {
try {
const raw = await fs.readFile(webviewsPath(configDir), 'utf-8');
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as Webview[]) : [];
} catch {
return [];
}
}
export async function writeWebviews(configDir: string, webviews: Webview[]): Promise<void> {
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
await fs.writeFile(webviewsPath(configDir), JSON.stringify(webviews, null, 2));
}