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
+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;
}