feat(web): support a reverse-proxy base URL (--base-url / CODEMAN_BASE_URL)

Codeman can now be mounted under a sub-path behind a reverse proxy that
forwards the prefix unchanged (e.g. https://host/codeman/). Default is `/`
(root), which is byte-identical to the historical behavior.

Design — few choke points, mirrored ingress/egress:
- src/config/base-path.ts: pure single-source normalize/validate/join/strip.
- Server ingress: stripBasePath() inside Fastify rewriteUrl, so routes stay
  declared prefix-agnostic; un-prefixed requests (hooks, health, docker bridge
  hitting the raw port) pass through unchanged.
- Server egress: one onSend hook prepends the base to root-absolute Location
  headers (covers all redirects).
- HTML: renderIndexHtml points <base href> at the mount and injects
  window.__CODEMAN_BASE__ — ONLY when a base is set (inert at root).
- Frontend runtime URLs: CodemanBase.url() route builder in constants.js,
  applied transparently by a fetch wrapper and explicitly at the
  EventSource/WebSocket/window.open/<img|iframe|a>-src sites.
- sw.js derives its base from self.location; manifest uses relative start_url/scope.
- Web-tab proxy: proxyPrefixFor(cap, basePath) is the single base-aware root that
  cascades to the injected <base>, HTML/attr rewrites, runtimeUrlShim, Set-Cookie
  Path and Location; capabilityFromReferer strips the base off the browser Referer,
  while the ingress parsers stay base-agnostic (rewriteUrl already stripped it).

--base-url rides the daemon relaunch (buildWebArgs) and the service unit
(resolveServicePlan). constants.js is guarded against a missing `window` for
isolated unit-test contexts.

Tests: test/base-path.test.ts (pure helpers), base-path coverage in
webview-proxy/render-index-html/daemon-control; CodemanBase stubbed in the
vm-isolated panels-ui test contexts. Docs: Remote-Access.md (sub-path section +
nginx example), security-architecture.md env table, CLAUDE.md pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XUkPBxbumnct6qSrx4JDju
This commit is contained in:
Michael Tiller
2026-09-04 16:08:57 -04:00
committed by Michael M. Tiller
co-authored by Claude Opus 4.8
parent 6f7add7ce4
commit 7e4914d991
28 changed files with 664 additions and 112 deletions
+29 -15
View File
@@ -39,6 +39,7 @@
*/
import { WEBVIEW_PROXY_PREFIX } from '../config/webview-limits.js';
import { stripBasePath } from '../config/base-path.js';
/** Headers that are per-connection and must never be relayed in either direction. */
const HOP_BY_HOP = new Set([
@@ -99,9 +100,18 @@ const DROP_RESPONSE_HEADERS = new Set([
'referrer-policy',
]);
/** The same-origin path prefix an iframe loads for a given capability. */
export function proxyPrefixFor(capability: string): string {
return `${WEBVIEW_PROXY_PREFIX}/${capability}/`;
/**
* The same-origin path prefix an iframe loads for a given capability.
*
* `basePath` is the reverse-proxy mount prefix (`''` at root, or `/foo`). It is
* INCLUDED here because this value is browser-facing — the iframe src, the
* `<base href>`, the runtime shim's rewrite target, Location/Set-Cookie rebasing —
* and all of those must ride the mount or they escape it. Requests coming the other
* way are base-stripped before routing, so the parsers (`capabilityFromProxyPath`,
* `resolveUpstreamUrl`) deliberately do NOT take a base.
*/
export function proxyPrefixFor(capability: string, basePath = ''): string {
return `${basePath}${WEBVIEW_PROXY_PREFIX}/${capability}/`;
}
/**
@@ -178,10 +188,13 @@ export function capabilityFromProxyPath(pathname: string): string | 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 {
export function capabilityFromReferer(referer: string | undefined, basePath = ''): string | null {
if (!referer) return null;
try {
return capabilityFromProxyPath(new URL(referer).pathname);
// The Referer is browser-supplied, so under a sub-path mount it carries the
// prefix (`/foo/webview/<cap>/...`); strip it back to the internal path the
// capability parser expects.
return capabilityFromProxyPath(stripBasePath(basePath, new URL(referer).pathname));
} catch {
return null;
}
@@ -232,7 +245,7 @@ export function isFramableCrossOrigin(xFrameOptions: string | undefined, csp: st
* 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 {
export function rewriteLocation(location: string, requestUrl: URL, capability: string, basePath = ''): string {
let resolved: URL;
try {
resolved = new URL(location, requestUrl);
@@ -241,7 +254,7 @@ export function rewriteLocation(location: string, requestUrl: URL, capability: s
}
if (resolved.origin !== requestUrl.origin) return location;
const suffix = resolved.pathname.replace(/^\//, '');
return `${proxyPrefixFor(capability)}${suffix}${resolved.search}${resolved.hash}`;
return `${proxyPrefixFor(capability, basePath)}${suffix}${resolved.search}${resolved.hash}`;
}
/**
@@ -252,7 +265,7 @@ export function rewriteLocation(location: string, requestUrl: URL, capability: s
* 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 {
export function rewriteSetCookie(cookie: string, capability: string, secureContext: boolean, basePath = ''): string {
const parts = cookie.split(';');
const out: string[] = [parts[0]];
let sawPath = false;
@@ -266,13 +279,13 @@ export function rewriteSetCookie(cookie: string, capability: string, secureConte
sawPath = true;
const value = attr.slice('path='.length);
const suffix = value.replace(/^\//, '');
out.push(`Path=${proxyPrefixFor(capability)}${suffix}`);
out.push(`Path=${proxyPrefixFor(capability, basePath)}${suffix}`);
continue;
}
out.push(attr);
}
if (!sawPath) out.push(`Path=${proxyPrefixFor(capability)}`);
if (!sawPath) out.push(`Path=${proxyPrefixFor(capability, basePath)}`);
return out.join('; ');
}
@@ -336,7 +349,8 @@ export function buildDownstreamResponseHeaders(
setCookies: string[],
capability: string,
requestUrl: URL,
secureContext: boolean
secureContext: boolean,
basePath = ''
): { headers: Record<string, string>; setCookie: string[]; csp: string | null } {
const headers: Record<string, string> = {};
let csp: string | null = null;
@@ -349,7 +363,7 @@ export function buildDownstreamResponseHeaders(
continue;
}
if (lower === 'location') {
headers['location'] = rewriteLocation(value, requestUrl, capability);
headers['location'] = rewriteLocation(value, requestUrl, capability, basePath);
continue;
}
if (DROP_RESPONSE_HEADERS.has(lower)) continue;
@@ -365,7 +379,7 @@ export function buildDownstreamResponseHeaders(
// override this; that is the dashboard author's own decision about their page.
headers['referrer-policy'] = 'same-origin';
const setCookie = setCookies.map((cookie) => rewriteSetCookie(cookie, capability, secureContext));
const setCookie = setCookies.map((cookie) => rewriteSetCookie(cookie, capability, secureContext, basePath));
return { headers, setCookie, csp };
}
@@ -593,8 +607,8 @@ try{
* 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);
export function rewriteHtml(html: string, capability: string, basePath = ''): string {
const prefix = proxyPrefixFor(capability, basePath);
// Fresh regexes per call: module-level /g patterns carry `lastIndex` between calls.
const rebased = html