Merge pull request #381 from mtiller/feat/reverse-proxy-base-url

feat(web): support a reverse-proxy base URL
This commit is contained in:
Ark0N
2026-09-06 23:10:23 +02:00
committed by GitHub
28 changed files with 664 additions and 112 deletions
+24 -2
View File
@@ -16,6 +16,7 @@ import { isAbsolute, join } from 'node:path';
import { homedir } from 'node:os';
import { dataPath } from './config/instance.js';
import { casePath } from './config/cases-dir.js';
import { assertValidBasePath } from './config/base-path.js';
import { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js';
import { getSessionManager } from './session-manager.js';
import { getTaskQueue } from './task-queue.js';
@@ -843,6 +844,11 @@ function addWebLaunchOptions(cmd: Command): Command {
.option('-H, --host <host>', 'Host to bind to', process.env.CODEMAN_HOST || '127.0.0.1')
.option('-p, --port <port>', 'Port to listen on (env: CODEMAN_PORT)', process.env.CODEMAN_PORT || '3000')
.option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)')
.option(
'--base-url <path>',
'Sub-path Codeman is mounted under behind a reverse proxy, e.g. /codeman (env: CODEMAN_BASE_URL)',
process.env.CODEMAN_BASE_URL || '/'
)
.option('--title-hostname <hostname>', 'Override the hostname shown in the browser title')
.option(
'--allow-unauthenticated-network',
@@ -859,6 +865,7 @@ function toWebLaunchOptions(options: {
host: string;
port: string;
https?: boolean;
baseUrl?: string;
titleHostname?: string;
allowUnauthenticatedNetwork?: boolean;
multiuser?: boolean;
@@ -868,10 +875,18 @@ function toWebLaunchOptions(options: {
console.error(palette.err(`✗ Invalid port: ${options.port}`));
process.exit(1);
}
let basePath: string;
try {
basePath = assertValidBasePath(options.baseUrl);
} catch (err) {
console.error(palette.err(`✗ ${err instanceof Error ? err.message : String(err)}`));
process.exit(1);
}
return {
host: options.host,
port,
https: !!options.https,
basePath,
titleHostname: options.titleHostname,
allowUnauthenticatedNetwork: !!options.allowUnauthenticatedNetwork,
multiuser: !!options.multiuser,
@@ -961,14 +976,21 @@ webCmd.action(async (options) => {
const https = launch.https;
const titleHostname = options.titleHostname;
const allowUnauthenticatedNetwork = launch.allowUnauthenticatedNetwork ?? false;
const basePath = launch.basePath ?? '';
// Single source of truth for subsystems that read it directly (e.g. renderers).
if (basePath) process.env.CODEMAN_BASE_URL = basePath;
const displayHost = host === '0.0.0.0' ? 'localhost' : host;
console.log(palette.info(`Starting Codeman web interface on ${displayHost}:${port}${https ? ' (HTTPS)' : ''}...`));
console.log(
palette.info(
`Starting Codeman web interface on ${displayHost}:${port}${basePath ? basePath + '/' : ''}${https ? ' (HTTPS)' : ''}...`
)
);
try {
// The server prints its own "running at" line (it also covers the daemon and
// service launch paths), so this one used to be a duplicate of it.
const server = await startWebServer(port, https, false, host, titleHostname, allowUnauthenticatedNetwork);
const server = await startWebServer(port, https, false, host, titleHostname, allowUnauthenticatedNetwork, basePath);
if (https) {
console.log(palette.warn(' Note: Accept the self-signed certificate in your browser on first visit'));
}
+101
View File
@@ -0,0 +1,101 @@
/**
* @fileoverview Reverse-proxy base-path support — the single source of truth for
* the URL prefix Codeman is mounted under.
*
* When Codeman runs behind a reverse proxy at a sub-path (e.g. `/codeman/`), the
* proxy forwards the FULL request path INCLUDING that prefix (it does not strip
* it). Every URL the server emits to the browser (the HTML shell, redirects,
* the manifest/service-worker) and every URL the browser builds (fetch/SSE/WS)
* must therefore carry the prefix too.
*
* This module normalizes the operator-supplied value (`--base-url` / the
* `CODEMAN_BASE_URL` env var) into ONE canonical form used everywhere:
* - `''` — mounted at the origin root (the default, `/`)
* - `/foo` — mounted at a sub-path (leading slash, NO trailing slash)
*
* Keeping the normalized form free of a trailing slash means `basePath + '/api/x'`
* and `basePath + '/'` both compose cleanly, and `''` degrades to the historical
* root behavior with no special-casing at the call sites.
*
* @module config/base-path
*/
/**
* A normalized base path is either empty (root) or one-or-more `/segment`
* groups, where a segment is a conservative, proxy-safe subset of path
* characters. This deliberately excludes anything that could change routing
* meaning (`?`, `#`, `:`, whitespace, `%`) so the prefix is a plain path.
*/
const VALID_BASE_PATH = /^(?:\/[A-Za-z0-9._~-]+)+$/;
/**
* Normalize an operator-supplied base path into the canonical form.
*
* Accepts loose input (`codeman`, `/codeman`, `/codeman/`, `//codeman//`) and
* returns `''` for root or `/codeman` otherwise. Does NOT validate the character
* set — call {@link assertValidBasePath} (or {@link isValidBasePath}) for that.
*/
export function normalizeBasePath(input: string | undefined | null): string {
if (input === undefined || input === null) return '';
let p = String(input).trim();
if (p === '' || p === '/') return '';
if (!p.startsWith('/')) p = '/' + p;
p = p.replace(/\/{2,}/g, '/'); // collapse duplicate slashes
p = p.replace(/\/+$/, ''); // drop trailing slash(es)
return p;
}
/** True if `normalized` is a legal canonical base path (`''` or `/seg[/seg...]`). */
export function isValidBasePath(normalized: string): boolean {
return normalized === '' || VALID_BASE_PATH.test(normalized);
}
/**
* Normalize AND validate, throwing a human-readable error on bad input. Used by
* the CLI so a typo (`--base-url /a b`, `--base-url ?x`) fails loudly at startup
* instead of silently producing broken URLs.
*/
export function assertValidBasePath(input: string | undefined | null): string {
const normalized = normalizeBasePath(input);
if (!isValidBasePath(normalized)) {
throw new Error(
`Invalid --base-url ${JSON.stringify(input)}: use a plain path like "/codeman" ` +
`(letters, digits, and ._~- in each segment).`
);
}
return normalized;
}
/**
* Join the base path onto a root-absolute application path (`/api/x` → `/base/api/x`).
*
* Leaves alone anything that is not a root-absolute app path: empty strings,
* protocol-relative (`//host`) and absolute URLs (`http://`, `ws://`, `data:`),
* fragments/queries, and paths already carrying the prefix. This is the one
* function the whole codebase routes URL construction through.
*/
export function joinBasePath(basePath: string, path: string): string {
if (!basePath) return path;
if (typeof path !== 'string' || path.length === 0) return path;
if (!path.startsWith('/')) return path; // relative / fragment / query — resolved against <base>
if (path.startsWith('//')) return path; // protocol-relative
if (path === basePath || path.startsWith(basePath + '/') || path.startsWith(basePath + '?')) {
return path; // already prefixed
}
return basePath + path;
}
/**
* Strip the base path off an INCOMING request URL so internal routing stays
* prefix-agnostic. Requests that arrive WITHOUT the prefix (health checks,
* hooks, the docker bridge — all of which hit the raw port, bypassing the proxy)
* are returned unchanged, so the server answers at both `/api/x` and
* `/base/api/x`.
*/
export function stripBasePath(basePath: string, url: string): string {
if (!basePath) return url;
if (url === basePath) return '/';
if (url.startsWith(basePath + '/')) return url.slice(basePath.length);
if (url.startsWith(basePath + '?')) return '/' + url.slice(basePath.length);
return url;
}
+3
View File
@@ -45,6 +45,8 @@ export interface WebLaunchOptions {
host: string;
port: number;
https: boolean;
/** Reverse-proxy sub-path prefix (normalized: '' for root, or '/foo'). */
basePath?: string;
titleHostname?: string;
allowUnauthenticatedNetwork?: boolean;
multiuser?: boolean;
@@ -87,6 +89,7 @@ export interface DaemonStatus {
export function buildWebArgs(options: WebLaunchOptions): string[] {
const args = ['web', '--host', options.host, '--port', String(options.port)];
if (options.https) args.push('--https');
if (options.basePath) args.push('--base-url', options.basePath);
if (options.titleHostname) args.push('--title-hostname', options.titleHostname);
if (options.allowUnauthenticatedNetwork) args.push('--allow-unauthenticated-network');
if (options.multiuser) args.push('--multiuser');
+17 -11
View File
@@ -142,7 +142,9 @@ function isPasswordChangeExempt(req: FastifyRequest): boolean {
* 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 {
function hasValidWebviewCapability(req: FastifyRequest, basePath = ''): boolean {
// req.url is already base-stripped by the server's rewriteUrl, so the path form
// needs no base; the Referer form below is browser-supplied and does.
const url = (req.url ?? '').split('?')[0];
const fromPath = capabilityFromProxyPath(url);
@@ -167,7 +169,10 @@ function hasValidWebviewCapability(req: FastifyRequest): boolean {
// class the 404 relay could never rescue. See matchesRegisteredRoute.
if (matchesRegisteredRoute(req, url)) return false;
const fromReferer = capabilityFromReferer(typeof req.headers.referer === 'string' ? req.headers.referer : undefined);
const fromReferer = capabilityFromReferer(
typeof req.headers.referer === 'string' ? req.headers.referer : undefined,
basePath
);
return !!fromReferer && webviewCapabilities.resolve(fromReferer) !== undefined;
}
@@ -210,7 +215,7 @@ function matchesRegisteredRoute(req: FastifyRequest, url: string): boolean {
*
* @returns AuthState for lifecycle management (dispose on server stop)
*/
export function registerAuthMiddleware(app: FastifyInstance, https: boolean): AuthState {
export function registerAuthMiddleware(app: FastifyInstance, https: boolean, basePath = ''): AuthState {
const state: AuthState = {
authSessions: null,
authFailures: null,
@@ -270,7 +275,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
ttlMs: AUTH_FAILURE_WINDOW_MS,
refreshOnGet: false,
});
registerMultiUserAuthHook(app, https, authSessions, authFailures, hookSecretFailures, state.userFailures);
registerMultiUserAuthHook(app, https, authSessions, authFailures, hookSecretFailures, state.userFailures, basePath);
return state;
}
@@ -293,7 +298,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
}
// Web-tab proxy, authenticated by the capability in the path, not the cookie.
if (hasValidWebviewCapability(req)) {
if (hasValidWebviewCapability(req, basePath)) {
done();
return;
}
@@ -381,7 +386,8 @@ function registerMultiUserAuthHook(
authSessions: StaleExpirationMap<string, AuthSessionRecord>,
authFailures: StaleExpirationMap<string, number>,
hookSecretFailures: StaleExpirationMap<string, number>,
userFailures: StaleExpirationMap<string, number>
userFailures: StaleExpirationMap<string, number>,
basePath = ''
): void {
const setSessionCookie = (reply: FastifyReply, token: string) =>
reply.setCookie(AUTH_COOKIE_NAME, token, {
@@ -432,7 +438,7 @@ function registerMultiUserAuthHook(
// `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;
if (hasValidWebviewCapability(req, basePath)) return;
const clientIp = req.ip;
@@ -552,7 +558,7 @@ const SAFE_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
*
* WebSocket upgrades are validated separately in the ws route handler.
*/
export function registerHostGuard(app: FastifyInstance, getPolicy: () => HostPolicy): void {
export function registerHostGuard(app: FastifyInstance, getPolicy: () => HostPolicy, basePath = ''): void {
app.addHook('onRequest', (req, reply, done) => {
const policy = getPolicy();
if (!isAllowedRequestHost(req.headers.host, policy)) {
@@ -568,7 +574,7 @@ export function registerHostGuard(app: FastifyInstance, getPolicy: () => HostPol
if (
!SAFE_HTTP_METHODS.has(req.method) &&
!isAllowedRequestOrigin(req.headers.origin, policy) &&
!hasValidWebviewCapability(req)
!hasValidWebviewCapability(req, basePath)
) {
reply.code(403).send('Forbidden: cross-site request blocked');
return;
@@ -580,7 +586,7 @@ export function registerHostGuard(app: FastifyInstance, getPolicy: () => HostPol
/**
* Register security headers and CORS middleware on every response.
*/
export function registerSecurityHeaders(app: FastifyInstance, https: boolean): void {
export function registerSecurityHeaders(app: FastifyInstance, https: boolean, basePath = ''): void {
// Gesture-control overlay (opt-in via CODEMAN_GESTURE=1) runs MediaPipe, which
// needs WebAssembly eval (script-src) and blob workers (worker-src). Its wasm
// runtime + model are self-hosted under /gesture/ (same-origin, covered by
@@ -634,7 +640,7 @@ export function registerSecurityHeaders(app: FastifyInstance, https: boolean): v
// 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)) {
if (req.method === 'OPTIONS' && !hasValidWebviewCapability(req, basePath)) {
reply.code(204).send();
done();
return;
+8 -4
View File
@@ -1268,7 +1268,11 @@ class CodemanApp {
if (typeof window !== 'undefined' && typeof window.__CODEMAN_SOLO__ === 'string' && window.__CODEMAN_SOLO__) {
return window.__CODEMAN_SOLO__;
}
const m = location.pathname.match(/^\/session\/([^/]+)\/?$/);
// Strip the reverse-proxy base so the match works under a sub-path mount.
const base = window.CodemanBase?.base || '';
let path = location.pathname;
if (base && path.startsWith(base)) path = path.slice(base.length) || '/';
const m = path.match(/^\/session\/([^/]+)\/?$/);
return m ? decodeURIComponent(m[1]) : null;
} catch { return null; }
}
@@ -1293,7 +1297,7 @@ class CodemanApp {
if (this.detachedSessions.has(id) && this._raiseDetached(id)) return;
const features = 'width=960,height=680,menubar=no,toolbar=no,location=no,status=no';
let win = null;
try { win = window.open('/session/' + encodeURIComponent(id), 'codeman-session-' + id, features); } catch {}
try { win = window.open(CodemanBase.url('/session/' + encodeURIComponent(id)), 'codeman-session-' + id, features); } catch {}
if (!win) {
this.showToast?.('Pop-out blocked — allow popups for this site to detach a session', 'error');
return;
@@ -1545,7 +1549,7 @@ class CodemanApp {
// regardless of filter (server side).
const _sseParams = new URLSearchParams({ clientId: this._clientId });
if (this.activeSessionId) _sseParams.set('sessions', this.activeSessionId);
this.eventSource = new EventSource(`/api/events?${_sseParams.toString()}`);
this.eventSource = new EventSource(CodemanBase.url(`/api/events?${_sseParams.toString()}`));
// Store all event listeners for cleanup on reconnect.
//
@@ -2794,7 +2798,7 @@ class CodemanApp {
// up to the limit).
const cid = this._clientId ? `${this._clientId}:${this._wsTabNonce}` : '';
const cidQuery = cid ? `?cid=${encodeURIComponent(cid)}` : '';
const url = `${proto}//${location.host}/ws/sessions/${sessionId}/terminal${cidQuery}`;
const url = `${proto}//${location.host}${CodemanBase.base}/ws/sessions/${sessionId}/terminal${cidQuery}`;
const ws = new WebSocket(url);
this._ws = ws;
this._wsSessionId = sessionId;
+57
View File
@@ -22,6 +22,63 @@
// Codeman — Shared constants and utility functions for frontend modules
// ═══════════════════════════════════════════════════════════════
// Reverse-proxy base path
// ═══════════════════════════════════════════════════════════════
// When Codeman is served behind a reverse proxy under a sub-path (e.g. /codeman/),
// the server injects `window.__CODEMAN_BASE__` (normalized: '' for root, or '/foo').
// The `<base href>` tag in index.html already rewrites the RELATIVE asset refs, but
// every URL the frontend builds at RUNTIME is root-absolute (`/api/...`, `/ws/...`)
// and root-absolute URLs ignore `<base>` — so those must be prefixed here instead.
// Rather than touch ~190 call sites, all runtime URL construction routes through this
// ONE choke point: `CodemanBase.url()` is the route builder, and a thin wrapper over
// `fetch` applies it transparently. The handful of EventSource/WebSocket sites call
// `CodemanBase.url()` / `CodemanBase.base` explicitly. No-op when mounted at root.
const CodemanBase = (function () {
// `window` is absent in some unit-test vm contexts that load this module in
// isolation; guard so the module still evaluates (base degrades to root).
const _win = typeof window !== 'undefined' ? window : undefined;
const base = String((_win && _win.__CODEMAN_BASE__) || '').replace(/\/+$/, '');
/**
* Prefix a root-absolute application path with the mount base. Leaves untouched:
* relative paths and fragments/queries (resolved against `<base>`), protocol-relative
* (`//host`) and absolute URLs, and paths already carrying the prefix.
*/
function url(path) {
if (!base) return path;
if (typeof path !== 'string' || path.length === 0) return path;
if (path[0] !== '/') return path; // relative / fragment / query
if (path[1] === '/') return path; // protocol-relative
if (path === base || path.startsWith(base + '/') || path.startsWith(base + '?')) return path;
return base + path;
}
return { base, url };
})();
if (typeof window !== 'undefined') window.CodemanBase = CodemanBase;
// Transparently prefix root-absolute app paths on every fetch, so the many
// `/api/...` string literals across the frontend need no per-call edit.
if (typeof window !== 'undefined' && CodemanBase.base && typeof window.fetch === 'function') {
const _origFetch = window.fetch.bind(window);
window.fetch = function (input, init) {
if (typeof input === 'string') return _origFetch(CodemanBase.url(input), init);
if (typeof Request !== 'undefined' && input instanceof Request) {
try {
const u = new URL(input.url);
if (u.origin === location.origin) {
const prefixed = CodemanBase.url(u.pathname);
if (prefixed !== u.pathname) {
return _origFetch(new Request(u.origin + prefixed + u.search + u.hash, input), init);
}
}
} catch (_e) {
/* not a parseable URL — fall through */
}
}
return _origFetch(input, init);
};
}
// ═══════════════════════════════════════════════════════════════
// Web Push Utilities
// ═══════════════════════════════════════════════════════════════
+1 -1
View File
@@ -308,7 +308,7 @@ const PathPicker = {
// A hidden file is only reachable while the toggle is on, and the preview
// endpoint re-resolves the path independently, so it needs the flag too.
if (this._showHidden) params.set('showHidden', 'true');
const previewUrl = `/api/filesystem/preview?${params.toString()}`;
const previewUrl = (window.CodemanBase?.url || ((p) => p))(`/api/filesystem/preview?${params.toString()}`);
const overlay = document.createElement('div');
overlay.className = 'path-preview-overlay';
+2 -1
View File
@@ -2,7 +2,8 @@
"name": "Codeman",
"short_name": "Codeman",
"description": "Claude Code session manager",
"start_url": "/",
"start_url": "./",
"scope": "./",
"display": "standalone",
"orientation": "any",
"background_color": "#0a0a0a",
+1 -1
View File
@@ -390,7 +390,7 @@ class NotificationManager {
const notif = new Notification(`${this.originalTitle}: ${localizedTitle}`, {
body: localizedBody,
tag, // Groups same-tag notifications
icon: '/favicon.ico',
icon: (window.CodemanBase?.url || ((p) => p))('/favicon.ico'),
silent: true, // We handle audio ourselves
});
+39 -32
View File
@@ -3424,7 +3424,7 @@ Object.assign(CodemanApp.prototype, {
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
const downloadBtn = !isDir
? `<a class="file-tree-download" href="${escapeHtml(`/api/sessions/${encodeURIComponent(owner)}/file-raw?path=${encodeURIComponent(node.path)}&download=true`)}" title="Download" onclick="event.stopPropagation()">&#x2B07;</a>`
? `<a class="file-tree-download" href="${escapeHtml(CodemanBase.url(`/api/sessions/${encodeURIComponent(owner)}/file-raw?path=${encodeURIComponent(node.path)}&download=true`))}" title="Download" onclick="event.stopPropagation()">&#x2B07;</a>`
: '';
html.push(`
@@ -3599,7 +3599,7 @@ Object.assign(CodemanApp.prototype, {
: '';
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
const downloadBtn = !isDir
? `<a class="file-tree-download" href="${escapeHtml(`/api/sessions/${ownerPath}/file-raw?path=${encodeURIComponent(match.path)}&download=true`)}" title="Download" onclick="event.stopPropagation()">&#x2B07;</a>`
? `<a class="file-tree-download" href="${escapeHtml(CodemanBase.url(`/api/sessions/${ownerPath}/file-raw?path=${encodeURIComponent(match.path)}&download=true`))}" title="Download" onclick="event.stopPropagation()">&#x2B07;</a>`
: '';
return `
<div class="file-tree-item" data-path="${escapeHtml(match.path)}" data-type="${escapeHtml(match.type)}" data-owner="${escapeHtml(ownerSessionId)}">
@@ -3998,18 +3998,20 @@ Object.assign(CodemanApp.prototype, {
// (html/htm arrive as a download there by design — file-raw serves them
// attachment-only so widening READ never widens RUN.)
const officeDoc = ext === 'docx' || ext === 'pptx';
this.filePreviewDetachUrl = attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/${officeDoc ? 'preview' : 'raw'}`
: officeDoc
? `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
this.filePreviewDetachUrl = CodemanBase.url(
attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/${officeDoc ? 'preview' : 'raw'}`
: officeDoc
? `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`
);
if (detachBtn) detachBtn.hidden = false;
// Registered attachment: render straight from its by-id routes — images and
// PDFs inline, Office docs via the server-converted PDF preview, text fetched
// raw. (Workspace-path previews fall through to the file-content endpoint.)
if (attachmentId) {
const base = `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`;
const base = CodemanBase.url(`/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`);
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
// VIDEO/AUDIO mirror VIDEO_ATTACHMENT_EXTENSIONS/AUDIO_ATTACHMENT_EXTENSIONS
// (src/attachment-registry.ts, the single source); the frontend cannot import
@@ -4067,13 +4069,13 @@ Object.assign(CodemanApp.prototype, {
// to file-content below, which would dump the binary bytes as mojibake.
if (ext === 'docx' || ext === 'pptx') {
footerEl.textContent = ext.toUpperCase();
const previewSrc = `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`;
const previewSrc = CodemanBase.url(`/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`);
bodyEl.innerHTML = `<iframe src="${escapeHtml(previewSrc)}" title="${escapeHtml(filePath)}"></iframe>`;
return;
}
if (ext === 'pdf') {
footerEl.textContent = 'PDF';
const rawSrc = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
const rawSrc = CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`);
bodyEl.innerHTML = `<iframe src="${escapeHtml(rawSrc)}" title="${escapeHtml(filePath)}"></iframe>`;
return;
}
@@ -4107,19 +4109,19 @@ Object.assign(CodemanApp.prototype, {
const data = result.data;
if (data.type === 'image') {
bodyEl.innerHTML = `<img src="${data.url}" alt="${escapeHtml(filePath)}">`;
bodyEl.innerHTML = `<img src="${escapeHtml(CodemanBase.url(data.url))}" alt="${escapeHtml(filePath)}">`;
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'video') {
// playsinline: iOS otherwise hijacks playback into its fullscreen
// player, which leaves the overlay behind it and its own close button
// as the only way back.
bodyEl.innerHTML = `<video src="${escapeHtml(data.url)}" controls autoplay playsinline preload="metadata"></video>`;
bodyEl.innerHTML = `<video src="${escapeHtml(CodemanBase.url(data.url))}" controls autoplay playsinline preload="metadata"></video>`;
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'audio') {
bodyEl.innerHTML = `<audio src="${escapeHtml(data.url)}" controls autoplay preload="metadata"></audio>`;
bodyEl.innerHTML = `<audio src="${escapeHtml(CodemanBase.url(data.url))}" controls autoplay preload="metadata"></audio>`;
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'binary') {
const downloadHref = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`;
const downloadHref = CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`);
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview<br><a href="${escapeHtml(downloadHref)}" download>Download</a></div>`;
footerEl.textContent = data.extension || 'binary';
} else {
@@ -4416,9 +4418,11 @@ Object.assign(CodemanApp.prototype, {
},
openAttachmentInNewTab(sessionId, filePath, attachmentId = null) {
const url = attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
const url = CodemanBase.url(
attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`
);
window.open(url, '_blank');
},
@@ -4454,19 +4458,22 @@ Object.assign(CodemanApp.prototype, {
const stack = this.ensureAttachmentCardStack();
const session = this.sessions.get(sessionId);
const sessionName = session?.name || sessionId.substring(0, 8);
const attachmentRawUrl =
const attachmentRawUrl = CodemanBase.url(
rawUrl ||
(attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`);
const attachmentPreviewUrl =
(attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`)
);
const attachmentPreviewUrl = CodemanBase.url(
previewUrl ||
(attachmentId ? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/preview` : null);
const attachmentThumbnailUrl =
(attachmentId ? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/preview` : null)
);
const attachmentThumbnailUrl = CodemanBase.url(
thumbnailUrl ||
(attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/thumbnail`
: `/api/sessions/${sessionId}/file-thumbnail?path=${encodeURIComponent(filePath)}`);
(attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/thumbnail`
: `/api/sessions/${sessionId}/file-thumbnail?path=${encodeURIComponent(filePath)}`)
);
const downloadUrl = attachmentId ? `${attachmentRawUrl}?download=true` : `${attachmentRawUrl}&download=true`;
const typeLabel = (extension || attachmentType || 'file').toUpperCase();
@@ -4730,7 +4737,7 @@ Object.assign(CodemanApp.prototype, {
.join(' • ');
const thumb =
item.thumbnailUrl && !item.missing
? `<img class="attachment-history-thumb-img" src="${escapeHtml(item.thumbnailUrl)}" alt="">`
? `<img class="attachment-history-thumb-img" src="${escapeHtml(CodemanBase.url(item.thumbnailUrl))}" alt="">`
: '';
const disabled = item.missing ? 'disabled aria-disabled="true"' : '';
return `
@@ -4770,7 +4777,7 @@ Object.assign(CodemanApp.prototype, {
const item = this.getAttachmentHistoryItem(itemId);
if (!item || item.missing) return;
if (item.rawUrl || item.url) {
window.open(item.rawUrl || item.url, '_blank');
window.open(CodemanBase.url(item.rawUrl || item.url), '_blank');
return;
}
this.openAttachmentInNewTab(item.sessionId, item.relativePath || item.fileName, item.attachmentId || null);
@@ -4779,7 +4786,7 @@ Object.assign(CodemanApp.prototype, {
downloadAttachmentHistoryItem(itemId) {
const item = this.getAttachmentHistoryItem(itemId);
if (!item || item.missing || !item.downloadUrl) return;
window.open(item.downloadUrl, '_blank');
window.open(CodemanBase.url(item.downloadUrl), '_blank');
},
reshowAttachmentCard(itemId) {
@@ -4925,7 +4932,7 @@ Object.assign(CodemanApp.prototype, {
// Connect to SSE stream
const eventSource = new EventSource(
`/api/sessions/${sessionId}/tail-file?path=${encodeURIComponent(filePath)}&lines=50`
CodemanBase.url(`/api/sessions/${sessionId}/tail-file?path=${encodeURIComponent(filePath)}&lines=50`)
);
eventSource.onmessage = (e) => {
@@ -5067,7 +5074,7 @@ Object.assign(CodemanApp.prototype, {
// Build image URL using the existing file-raw endpoint
// Use relativePath (path from working dir) instead of fileName (basename) for subdirectory images
const imageUrl = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(relativePath || fileName)}`;
const imageUrl = CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(relativePath || fileName)}`);
// Create window element
const win = document.createElement('div');
+1 -1
View File
@@ -3364,7 +3364,7 @@ Object.assign(CodemanApp.prototype, {
return `<div class="case-manage-item" style="display:flex; align-items:center; gap:8px; justify-content:space-between;">
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="${nm}">${nm} <span class="form-hint">(${mb} MB)</span></span>
<span style="flex-shrink:0;">
<a class="btn-toolbar" href="/api/docker-exports/${encodeURIComponent(e.name)}" download>Download</a>
<a class="btn-toolbar" href="${CodemanBase.url(`/api/docker-exports/${encodeURIComponent(e.name)}`)}" download>Download</a>
<button class="btn-toolbar" onclick="app.importDockerBundle('${nm.replace(/'/g, "\\'")}')">Import</button>
<button class="btn-toolbar" onclick="app.deleteDockerExport('${nm.replace(/'/g, "\\'")}')">Delete</button>
</span>
+4 -1
View File
@@ -187,7 +187,10 @@ Object.assign(CodemanApp.prototype, {
registerServiceWorker() {
if (!('serviceWorker' in navigator)) return;
navigator.serviceWorker.register('/sw.js').then((reg) => {
// Behind a sub-path mount the worker is served at <base>/sw.js and controls
// <base>/ (Service-Worker-Allowed is '/', so this narrower scope is permitted).
const _swBase = window.CodemanBase?.base || '';
navigator.serviceWorker.register(_swBase + '/sw.js', { scope: _swBase + '/' }).then((reg) => {
this._swRegistration = reg;
// Listen for messages from service worker (notification clicks)
navigator.serviceWorker.addEventListener('message', (event) => {
+13 -6
View File
@@ -20,6 +20,13 @@
const CACHE_NAME = 'codeman-v1';
// Reverse-proxy base path: the worker is served at `<base>/sw.js`, so its own
// location tells us the mount prefix ('' at root, or '/codeman'). Every URL below
// is prefixed through B() so the cached shell, icons and API calls resolve under
// the mount instead of escaping to the origin root.
const SW_BASE = self.location.pathname.replace(/\/sw\.js$/, '');
const B = (p) => (p && p[0] === '/' ? SW_BASE + p : p);
// Core app shell -- cached on install for instant startup
const APP_SHELL = [
'/',
@@ -45,7 +52,7 @@ const APP_SHELL = [
'/icon-192.png',
'/icon-512.png',
'/manifest.json',
];
].map(B);
// --- Install: precache app shell ---
@@ -116,9 +123,9 @@ self.addEventListener('push', (event) => {
const options = {
body: body || '',
tag: tag || 'codeman-default',
icon: '/icon-192.png',
badge: '/icon-192.png',
data: { sessionId, approvalId, url: sessionId ? `/?session=${sessionId}` : '/' },
icon: B('/icon-192.png'),
badge: B('/icon-192.png'),
data: { sessionId, approvalId, url: sessionId ? B(`/?session=${sessionId}`) : B('/') },
renotify: true,
requireInteraction: urgency === 'critical',
};
@@ -143,7 +150,7 @@ self.addEventListener('notificationclick', (event) => {
event.notification.close();
const { sessionId, approvalId, url } = event.notification.data || {};
const targetUrl = url || '/';
const targetUrl = url || B('/');
const action = event.action || null;
// Approve/Deny action buttons answer the Approvals Inbox item directly from
@@ -152,7 +159,7 @@ self.addEventListener('notificationclick', (event) => {
// because a service worker fetch carries the worker's own (same) origin.
if ((action === 'approve' || action === 'deny') && approvalId) {
event.waitUntil(
fetch(`/api/approvals/${encodeURIComponent(approvalId)}/answer`, {
fetch(B(`/api/approvals/${encodeURIComponent(approvalId)}/answer`), {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
+1 -1
View File
@@ -322,7 +322,7 @@ const ClaudeVoiceProvider = {
if (opts.keyterms?.length) params.set('keyterms', opts.keyterms.join(','));
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
try {
this._ws = new WebSocket(`${proto}//${location.host}/ws/voice/stream?${params}`);
this._ws = new WebSocket(`${proto}//${location.host}${window.CodemanBase?.base || ''}/ws/voice/stream?${params}`);
} catch (err) {
this._onError?.('Failed to open voice stream: ' + err.message);
this._cleanup();
+3 -1
View File
@@ -182,7 +182,9 @@ Object.assign(CodemanApp.prototype, {
if (webview.trusted) sandbox.push('allow-same-origin');
frame.setAttribute('sandbox', sandbox.join(' '));
frame.setAttribute('referrerpolicy', 'no-referrer-when-downgrade');
frame.src = src;
// Proxied dashboards carry a root-absolute `/webview/<cap>/` embedUrl that must
// ride the mount prefix; external (trusted) URLs are absolute and pass through.
frame.src = CodemanBase.url(src);
const failure = document.createElement('div');
failure.className = 'webview-failure';
+37 -21
View File
@@ -102,14 +102,14 @@ function withWebviews<T>(fn: (list: Webview[]) => Promise<T> | T): Promise<T> {
return next;
}
export function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort): void {
registerCrudRoutes(app, ctx);
registerProxyRoutes(app);
export function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort, basePath = ''): void {
registerCrudRoutes(app, ctx, basePath);
registerProxyRoutes(app, basePath);
}
// ───────────────────────────── CRUD ─────────────────────────────
function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort): void {
function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort, basePath: string): void {
app.get('/api/webviews', async (req) => {
const user = getAuthUser(req);
const all = await readWebviews(configDir());
@@ -279,7 +279,7 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort
}
const capability = webviewCapabilities.mint(webview.id, webview.owner);
const data: WebviewOpenData = { webview, embedUrl: proxyPrefixFor(capability) };
const data: WebviewOpenData = { webview, embedUrl: proxyPrefixFor(capability, basePath) };
return { success: true, data };
});
}
@@ -347,7 +347,7 @@ async function probeUrl(url: string): Promise<WebviewProbe> {
// ───────────────────────────── Proxy ─────────────────────────────
function registerProxyRoutes(app: FastifyInstance): void {
function registerProxyRoutes(app: FastifyInstance, basePath: string): 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
@@ -358,11 +358,11 @@ function registerProxyRoutes(app: FastifyInstance): void {
// A single GET route serving both roles: `handler` for normal requests,
// `wsHandler` for upgrades. Registering them as two routes on one URL would
// collide.
// collide. (The WS leg produces no browser-facing URLs, so it needs no base.)
scope.route<{ Params: ProxyParams }>({
method: 'GET',
url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`,
handler: proxyHttp,
handler: (req, reply) => proxyHttp(req, reply, basePath),
wsHandler: proxyWebSocket,
});
@@ -371,14 +371,14 @@ function registerProxyRoutes(app: FastifyInstance): void {
scope.route<{ Params: ProxyParams }>({
method: ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`,
handler: proxyHttp,
handler: (req, reply) => proxyHttp(req, reply, basePath),
});
// `/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);
return reply.redirect(proxyPrefixFor(req.params.cap, basePath), 302);
});
});
}
@@ -406,8 +406,12 @@ async function lookupCapability(capability: string): Promise<Webview | null> {
* 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['*'] ?? '');
function proxyHttp(
req: FastifyRequest<{ Params: ProxyParams }>,
reply: FastifyReply,
basePath: string
): Promise<FastifyReply> {
return proxyRequest(req, reply, req.params.cap, req.params['*'] ?? '', basePath);
}
/**
@@ -419,7 +423,8 @@ async function proxyRequest(
req: FastifyRequest,
reply: FastifyReply,
cap: string,
wildcard: string
wildcard: string,
basePath = ''
): Promise<FastifyReply> {
const webview = await lookupCapability(cap);
if (!webview) {
@@ -454,7 +459,8 @@ async function proxyRequest(
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,
refererPath:
typeof req.headers.referer === 'string' ? stripProxyPrefix(req.headers.referer, cap, basePath) : undefined,
});
// #237: the timeout bounds TIME-TO-HEADERS only. A plain AbortSignal.timeout on
@@ -546,7 +552,8 @@ async function proxyRequest(
response.headers.getSetCookie(),
cap,
upstream,
secureContext
secureContext,
basePath
);
reply.code(response.status);
@@ -575,7 +582,7 @@ async function proxyRequest(
// 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(html.length <= MAX_WEBVIEW_HTML_REWRITE_BYTES ? rewriteHtml(html, cap, basePath) : html);
}
return reply.send(Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]));
@@ -596,25 +603,34 @@ async function proxyRequest(
*
* @returns true when the request was handled (caller must not also reply).
*/
export async function tryWebviewRefererFallback(req: FastifyRequest, reply: FastifyReply): Promise<boolean> {
export async function tryWebviewRefererFallback(
req: FastifyRequest,
reply: FastifyReply,
basePath = ''
): 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);
const capability = capabilityFromReferer(
typeof req.headers.referer === 'string' ? req.headers.referer : undefined,
basePath
);
if (!capability) return false;
if (!webviewCapabilities.resolve(capability)) return false;
// req.url is already base-stripped by the server's rewriteUrl, so this is the
// internal path the upstream resolver expects.
const path = req.url.split('?')[0].replace(/^\//, '');
await proxyRequest(req, reply, capability, path);
await proxyRequest(req, reply, capability, path, basePath);
return true;
}
/** Turn a proxy-side Referer back into the upstream path it corresponds to. */
function stripProxyPrefix(referer: string, capability: string): string | undefined {
function stripProxyPrefix(referer: string, capability: string, basePath = ''): string | undefined {
try {
const url = new URL(referer);
const prefix = proxyPrefixFor(capability);
const prefix = proxyPrefixFor(capability, basePath);
if (!url.pathname.startsWith(prefix)) return undefined;
return `/${url.pathname.slice(prefix.length)}${url.search}`;
} catch {
+65 -14
View File
@@ -41,6 +41,7 @@ import fs from 'node:fs/promises';
import { execSync } from 'node:child_process';
import { hostname as getHostname } from 'node:os';
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
import { normalizeBasePath, stripBasePath, joinBasePath } from '../config/base-path.js';
import { GLYPH, palette } from '../cli-style.js';
import { getHookSecret } from '../config/hook-secret.js';
import { EventEmitter } from 'node:events';
@@ -260,6 +261,8 @@ export class WebServer extends EventEmitter {
private port: number;
private host: string;
private https: boolean;
/** Reverse-proxy sub-path prefix (normalized: '' for root, or '/foo'). */
private basePath: string;
private testMode: boolean;
private mux: TerminalMultiplexer;
// Centralized cleanup for standalone timers (intervals + resettable timeouts)
@@ -330,13 +333,16 @@ export class WebServer extends EventEmitter {
testMode: boolean = false,
host: string = '127.0.0.1',
titleHostname?: string,
allowUnauthenticatedNetwork: boolean = false
allowUnauthenticatedNetwork: boolean = false,
basePath: string = ''
) {
super();
this.setMaxListeners(0);
this.host = host;
this.port = port;
this.https = https;
// Normalize so callers may pass raw operator input; '' == mounted at root.
this.basePath = normalizeBasePath(basePath || process.env.CODEMAN_BASE_URL);
this.testMode = testMode;
this.allowUnauthenticatedNetwork =
allowUnauthenticatedNetwork || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK);
@@ -344,7 +350,12 @@ export class WebServer extends EventEmitter {
this.windowTitle = `codeman:${this.titleHostname}`;
this.indexHtmlTemplate = readFileSync(join(__dirname, 'public', 'index.html'), 'utf-8');
const rewriteUrl = (req: { url?: string }): string => rewriteApiV1Url(req.url || '');
// Ingress: strip the reverse-proxy prefix so all internal routing stays
// prefix-agnostic (routes are still declared at `/api/...`, `/`, `/ws/...`).
// Requests that arrive WITHOUT the prefix (hooks, health checks, the docker
// bridge — all hitting the raw port) pass through unchanged. Then apply the
// existing /api/v1 alias rewrite.
const rewriteUrl = (req: { url?: string }): string => rewriteApiV1Url(stripBasePath(this.basePath, req.url || ''));
if (https) {
const { key, cert } = getOrCreateSelfSignedCert();
this.app = Fastify({ logger: false, https: { key, cert }, rewriteUrl });
@@ -725,6 +736,21 @@ export class WebServer extends EventEmitter {
// Cookie plugin (needed for auth session tokens)
await this.app.register(fastifyCookie);
// Egress: when mounted under a reverse-proxy sub-path, any root-absolute
// `Location` we emit (redirects in system/webview/file routes, `/`, `/api/...`)
// must carry the prefix or the browser resolves it against the origin root and
// escapes the mount. One hook covers every current and future redirect, mirroring
// the ingress strip in `rewriteUrl`. No-op when mounted at root (basePath === '').
if (this.basePath) {
this.app.addHook('onSend', (_req, reply, payload, done) => {
const loc = reply.getHeader('location');
if (typeof loc === 'string') {
reply.header('location', joinBasePath(this.basePath, loc));
}
done(null, payload);
});
}
// Uniform response envelope (stable HTTP contract — docs/api-reference.md):
// wrap bare JSON payloads as { success:true, data } and map { success:false }
// error envelopes to a conventional HTTP status (instead of 200). Skips
@@ -749,10 +775,10 @@ export class WebServer extends EventEmitter {
// Anti-DNS-rebinding Host allowlist + cross-site (CSRF) Origin guard. Registered
// before auth so forged cross-site / rebound requests are rejected up front, even
// on the default no-password install. See docs/reports/security-review-2026-06-09.md.
registerHostGuard(this.app, () => this.getHostPolicy());
registerHostGuard(this.app, () => this.getHostPolicy(), this.basePath);
// Auth middleware (Basic Auth + session cookies + rate limiting)
const authState = registerAuthMiddleware(this.app, this.https);
const authState = registerAuthMiddleware(this.app, this.https, this.basePath);
if (authState) {
this.authSessions = authState.authSessions;
this.authFailures = authState.authFailures;
@@ -777,7 +803,7 @@ export class WebServer extends EventEmitter {
});
// Security headers + CORS
registerSecurityHeaders(this.app, this.https);
registerSecurityHeaders(this.app, this.https, this.basePath);
this.app.get('/', async (_req, reply) => {
return reply
.header('Cache-Control', 'no-cache')
@@ -949,7 +975,7 @@ export class WebServer extends EventEmitter {
// rescue. Reaching this handler at all already proves no Codeman route matched,
// and the relay declines unless the Referer carries a live capability, so
// genuinely unknown `/api` paths still get the envelope below.
if (await tryWebviewRefererFallback(req, reply)) return reply;
if (await tryWebviewRefererFallback(req, reply, this.basePath)) return reply;
if (req.url.startsWith('/api')) {
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, notFound));
}
@@ -1023,7 +1049,7 @@ export class WebServer extends EventEmitter {
registerMeRoutes(this.app, ctx);
registerAdminRoutes(this.app, ctx);
registerOrchestratorRoutes(this.app, ctx);
registerWebviewRoutes(this.app, ctx);
registerWebviewRoutes(this.app, ctx, this.basePath);
registerTabLayoutRoutes(this.app, ctx);
// Cron: build the service from the same context, recompute
@@ -1383,6 +1409,20 @@ export class WebServer extends EventEmitter {
'<title>Codeman</title>',
`<title>${escapeHtmlText(this.windowTitle)}</title>`
);
// Reverse-proxy sub-path support. The template ships `<base href="/">` and all
// static asset refs are RELATIVE, so pointing the base at the mount prefix
// rewrites every asset URL for free. `window.__CODEMAN_BASE__` gives the
// frontend the same prefix for the root-absolute URLs it builds at runtime
// (fetch/SSE/WS), which `<base>` cannot touch. Injected right after `<base>`
// so it is set before any (deferred) script runs. ONLY when a base is set —
// at root ('') the template is left byte-identical to the historical output
// (the frontend reads a missing `__CODEMAN_BASE__` as root anyway).
if (this.basePath) {
html = html.replace(
'<base href="/">',
`<base href="${escapeHtmlText(this.basePath + '/')}">\n <script>window.__CODEMAN_BASE__=${JSON.stringify(this.basePath)};</script>`
);
}
// Cache-bust same-origin module scripts + stylesheets so a normal reload
// always serves the latest (static assets carry a 1-year immutable cache).
html = this.cacheBustAssets(html);
@@ -1492,10 +1532,9 @@ export class WebServer extends EventEmitter {
html = html.replace('</head>', `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
if (settings.gestureControlEnabled === true) {
const v = this.gestureBundleVersion();
html = html.replace(
'</head>',
`<script type="module" src="/gesture/gesture-codeman.js${v}"></script>\n</head>`
);
// Relative src so the injected `<base href>` resolves it under the mount
// prefix (a root-absolute `/gesture/...` would escape a sub-path mount).
html = html.replace('</head>', `<script type="module" src="gesture/gesture-codeman.js${v}"></script>\n</head>`);
}
}
return html;
@@ -2490,7 +2529,18 @@ export class WebServer extends EventEmitter {
const displayHost = this.host === '0.0.0.0' ? 'localhost' : this.host;
// The only startup banner: `codeman web` used to print its own copy of this
// line, but the daemon and service launch paths never go through the CLI.
console.log(palette.ok(`${GLYPH.ok} Codeman web interface running at ${protocol}://${displayHost}:${this.port}`));
console.log(
palette.ok(
`${GLYPH.ok} Codeman web interface running at ${protocol}://${displayHost}:${this.port}${this.basePath}${this.basePath ? '/' : ''}`
)
);
if (this.basePath) {
console.log(
palette.muted(
` Mounted under base path ${this.basePath} (front it with a reverse proxy that forwards ${this.basePath}/ unchanged).`
)
);
}
// Opt-in: also serve the HOOK endpoints on the docker bridge gateway so
// in-container hooks (permission/idle/stop callbacks) can reach a loopback-bound
@@ -3339,9 +3389,10 @@ export async function startWebServer(
testMode: boolean = false,
host: string = '127.0.0.1',
titleHostname?: string,
allowUnauthenticatedNetwork: boolean = false
allowUnauthenticatedNetwork: boolean = false,
basePath: string = ''
): Promise<WebServer> {
const server = new WebServer(port, https, testMode, host, titleHostname, allowUnauthenticatedNetwork);
const server = new WebServer(port, https, testMode, host, titleHostname, allowUnauthenticatedNetwork, basePath);
await server.start();
return server;
}
+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