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
+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' },