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