feat(web-tabs): open dashboard URLs as tabs beside agent sessions

Adds a "Web / URL" section to the Run dropdown. A saved URL renders as a tab in
the same strip as Claude/Codex/Gemini sessions, with the same Alt+1..9 numbering,
so Codeman is one mission control instead of Codeman plus a pile of browser tabs.

A webview is NOT a sixth SessionMode: no PTY, no tmux, no respawn, no idle
detection. It is a separate resource sharing only the tab strip and the main
content area, the same call that keeps Docker and remote-SSH as case overlays.

Dashboards are proxied through Codeman's own origin, because a direct iframe
fails three ways at once in the shipped deployment: prod serves HTTPS behind
tailscale serve, so http:// targets are hard-blocked as mixed content (with no
override at all on iOS Safari); Grafana/Portainer-class dashboards send
X-Frame-Options: DENY; and our own default-src 'self' CSP blocks cross-origin
frames. Proxying dissolves all three and leaves the production CSP byte-for-byte
unchanged, since /webview/... is already covered by 'self'. A useful side effect:
the fetch happens server-side, so a tailnet-only dashboard is reachable from a
phone that is not on the tailnet.

The proxy is not an API surface. It authenticates on a 192-bit capability in the
path (memory-only, rolling TTL, bound to the minting user, revoked on edit or
delete) and is correspondingly exempt from the cookie and Origin checks, because
a sandboxed iframe is opaque-origin: it sends no SameSite=lax cookie and its
writes arrive with Origin: null. The Host allowlist is never bypassed. A second
Referer-keyed form of the exemption exists for root-absolute assets and is fenced
to safe methods on non-/api, non-/ws, non-/q paths.

Iframes omit allow-same-origin unless a URL is explicitly marked trusted, since a
proxied page is served from Codeman's own origin and could otherwise read this
document and drive the agent-spawning API. Authorization and codeman_session are
stripped upstream in BOTH modes, so CODEMAN_PASSWORD cannot leak into a dashboard.

Two things only a real browser reveals, both presenting as the dashboard's own
"Failed to fetch" while the page itself renders fine:

- Runtime-built root-absolute URLs (fetch('/api/data')) escape <base href> and
  land on Codeman's root. Widening the Referer fallback into /api would trade
  security for it, so an injected shim patches fetch/XHR/WebSocket/EventSource
  inside the frame instead, removing the class rather than the guard.
- An opaque-origin document CORS-checks every request, including to the host it
  was served from. Script/css/img loads are not CORS-checked, which is why the
  page renders while its API calls die. The proxy now emits CORS headers and
  answers preflights itself. registerSecurityHeaders answered every OPTIONS with
  a bare 204 before routing, carrying no ACAO for Origin: null, so that
  short-circuit now exempts a valid capability.

Neither is reproducible with curl, which does not enforce CORS.

Also fixes a pre-existing bug found on the way: .toolbar has backdrop-filter,
making it a stacking context that trapped .run-mode-menu's z-index:1000, so
.welcome-overlay painted over the whole Run menu. With no session open, every
item in it (Claude Code included) was unclickable.

Verified end to end against a real tailnet dashboard: live data, WebSocket push,
no failed requests, and switching tabs does not reload the frame. 98 new tests
cover the pure rewrite helpers, the CORS helper, the shim's rewrite logic, route
CRUD, and every edge of the auth exemption.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-27 17:06:36 +02:00
parent ea4c935d51
commit b34fcaf928
26 changed files with 3316 additions and 18 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';
+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',
};
// ═══════════════════════════════════════════════════════════════
+64
View File
@@ -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">
@@ -464,6 +469,14 @@
<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>
@@ -639,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>
@@ -2551,6 +2614,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>
+121
View File
@@ -3129,6 +3129,12 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
/* contain: style only — layout/paint containment clips the case-settings popover
that extends above the toolbar (popover uses position:absolute + bottom:100%) */
contain: style;
/* backdrop-filter above makes this a stacking context, which TRAPS the
z-index:1000 on .run-mode-menu inside it. Without an explicit z-index here the
toolbar resolves to auto (0) and .welcome-overlay (z-index:10, inside <main>)
paints over the popped-up Run menu: with no session open, every item in that
menu is unclickable. Must stay below .modal (1000). */
z-index: 20;
}
/* backdrop-filter creates a stacking context, trapping the popover's
@@ -11854,3 +11860,118 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
display: flex; gap: 0.5rem; justify-content: flex-end;
margin-top: 1rem; padding-top: 0.85rem; border-top: 1px solid var(--border);
}
/* ═══════════════════════════════════════════════════════════════
Web tabs (dashboard URLs embedded as tabs)
═══════════════════════════════════════════════════════════════ */
/* The iframe layer sits alongside .terminal-wrap inside <main> and only one of
the two is visible at a time. Frames stay in the DOM while hidden so switching
tabs does not reload a dashboard. */
.webview-layer {
display: none;
flex: 1;
min-height: 0;
position: relative;
background: var(--term-bg, #161b23);
}
.main.webview-active .webview-layer { display: flex; }
.main.webview-active .terminal-wrap { display: none; }
.webview-frame {
display: none;
position: absolute;
inset: 0;
}
.webview-frame.active { display: block; }
.webview-iframe {
width: 100%;
height: 100%;
border: 0;
display: block;
background: #fff;
}
/* Shown only when the frame never signalled load: a refused embed or an
unreachable host would otherwise be an unexplained blank rectangle. */
.webview-failure { display: none; }
.webview-frame--failed .webview-failure {
display: flex;
position: absolute;
inset: 0;
align-items: center;
justify-content: center;
background: var(--bg, #0f1319);
padding: 1.5rem;
text-align: center;
}
.webview-failure-inner { max-width: 380px; }
.webview-failure-inner h3 { margin: 0 0 0.5rem; font-size: 1rem; color: var(--text); }
.webview-failure-inner p { margin: 0 0 1rem; font-size: 0.85rem; color: var(--text-dim); line-height: 1.5; }
.webview-failure-actions { display: flex; gap: 0.5rem; justify-content: center; flex-wrap: wrap; }
/* Tab styling: same shape as a session tab, distinguished by the globe icon and
a cool accent so a dashboard never reads as a running agent. */
.session-tab--web .tab-web-icon {
display: inline-flex;
align-items: center;
color: var(--text-dim);
flex-shrink: 0;
}
.session-tab--web.active .tab-web-icon { color: var(--accent, #3ec8ee); }
.session-tab--web.active { border-bottom-color: var(--accent, #3ec8ee); }
.run-mode-dot.web { background: #38bdf8; }
.run-mode-webviews { max-height: 180px; overflow-y: auto; }
.run-mode-empty {
padding: 4px 10px 6px;
font-size: 0.75em;
color: var(--text-dim);
font-style: italic;
}
/* Icon picker: a compact grid above the free-text field, so the common case is a
click and the escape hatch (any emoji at all) stays available. */
.webview-icon-picker {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.webview-icon-choice {
width: 34px;
height: 34px;
font-size: 1.05rem;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-soft, rgba(255, 255, 255, 0.03));
cursor: pointer;
padding: 0;
}
.webview-icon-choice:hover { border-color: var(--accent, #3ec8ee); }
.webview-icon-choice.selected {
border-color: var(--accent, #3ec8ee);
box-shadow: 0 0 0 1px var(--accent, #3ec8ee) inset;
}
/* Saved-URL rows in the Run dropdown show their chosen icon in the dot's slot. */
.run-mode-menu-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
margin-right: 6px;
font-size: 0.95em;
flex-shrink: 0;
}
.webview-probe-result.ok { color: var(--success, #10b981); }
.webview-probe-result.bad { color: var(--danger, #ef4444); }
/* Delete sits apart from Cancel/Save so it is not fat-fingered on the way to Save. */
.webview-modal-actions { justify-content: space-between; }
.webview-modal-actions .btn-danger { margin-right: auto; }
+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();
},
});
+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');
});
})();
}
+40
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,
@@ -1184,3 +1185,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));
}