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
+36
View File
@@ -684,3 +684,39 @@ describe('referrer policy on proxied responses', () => {
expect(headers['referrer-policy']).toBe('same-origin');
});
});
describe('reverse-proxy base path', () => {
const BASE = '/codeman';
const BASED_PREFIX = `${BASE}/webview/${CAP}/`;
it('rides the mount into the iframe prefix', () => {
expect(proxyPrefixFor(CAP, BASE)).toBe(BASED_PREFIX);
expect(proxyPrefixFor(CAP, '')).toBe(PREFIX); // root unchanged
});
it('rewrites HTML (base tag, root-absolute attrs, shim) under the mount', () => {
const out = rewriteHtml('<html><head></head><body><img src="/logo.png"></body></html>', CAP, BASE);
expect(out).toContain(`<base href="${BASED_PREFIX}">`);
expect(out).toContain(`src="${BASED_PREFIX}logo.png"`);
// The runtime shim's rewrite target is the base-prefixed path.
expect(out).toContain(JSON.stringify(BASED_PREFIX));
});
it('rebases Set-Cookie Path onto the mounted prefix so the browser sends it back', () => {
expect(rewriteSetCookie('sid=abc; Path=/', CAP, true, BASE)).toContain(`Path=${BASED_PREFIX}`);
expect(rewriteSetCookie('sid=abc; HttpOnly', CAP, true, BASE)).toContain(`Path=${BASED_PREFIX}`);
});
it('rewrites a same-origin Location into the mounted prefix', () => {
const requestUrl = new URL('http://127.0.0.1:4000/app');
expect(rewriteLocation('/dashboard?x=1', requestUrl, CAP, BASE)).toBe(`${BASED_PREFIX}dashboard?x=1`);
});
it('extracts the capability from a browser Referer that carries the mount prefix', () => {
expect(capabilityFromReferer(`https://box.ts.net${BASED_PREFIX}page`, BASE)).toBe(CAP);
// A same-named sibling path must not be mistaken for the mount.
expect(capabilityFromReferer(`https://box.ts.net/codeman-docs/webview/${CAP}/page`, BASE)).toBeNull();
// Without the base arg the prefixed Referer no longer matches (documents why the arg exists).
expect(capabilityFromReferer(`https://box.ts.net${BASED_PREFIX}page`)).toBeNull();
});
});