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
+1
View File
@@ -529,6 +529,7 @@ A saved dashboard URL renders as a tab, served through Codeman's own origin at `
| `CODEMAN_PASSWORD` (+ `CODEMAN_USERNAME`) | Enable HTTP Basic auth |
| `--host` / `CODEMAN_HOST` | Bind host (default `127.0.0.1`) |
| `CODEMAN_ALLOWED_HOSTS` | Extra `Host`/`Origin` allowlist entries for reverse proxies (comma‑separated; exact host, or leading‑dot `.suffix` for subdomains) — see §3 |
| `--base-url` / `CODEMAN_BASE_URL` | Sub‑path prefix Codeman is mounted under behind a reverse proxy, e.g. `/codeman` (default `/`); the proxy must forward the prefix unchanged. Independent of `CODEMAN_ALLOWED_HOSTS` |
| `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledge an unauthenticated non‑loopback bind (downgrades the warning) |
| `--https` | Enable TLS (adds HSTS) |
| `CODEMAN_INSTANCE` | Scope tmux socket + data dir for isolation |
+42
View File
@@ -167,6 +167,47 @@ and is not one.
Also make sure the proxy forwards WebSocket upgrades. The terminal is a WebSocket, and the
upgrade runs the same Host and Origin checks, closing with code `4003` on failure.
### Mounting under a sub-path
By default Codeman assumes it is served at the origin root (`/`). To mount it under a
sub-path — e.g. `https://example.com/codeman/` — start it with `--base-url` (or the
`CODEMAN_BASE_URL` env var):
```bash
codeman web --base-url /codeman
# or
CODEMAN_BASE_URL=/codeman codeman web
```
The value is a plain path prefix; `/` (the default) means "mounted at the root". With a
prefix set, Codeman emits every URL — the HTML shell and its assets, API/SSE/WebSocket
calls, redirects, the PWA manifest and the service worker — under that prefix, so a browser
loading `https://example.com/codeman/` stays inside the mount.
**Forward the prefix unchanged — do NOT strip it.** Codeman expects the proxy to pass the
full path (including `/codeman/`) straight through. A minimal nginx block:
```nginx
location /codeman/ {
proxy_pass http://127.0.0.1:3000; # note: no trailing slash — keep the /codeman/ prefix
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade; # WebSocket
proxy_set_header Connection "upgrade";
}
```
Notes and current limits:
- The prefix must still be paired with `CODEMAN_ALLOWED_HOSTS` for your domain, exactly as
above — the two are independent.
- Health checks, Claude Code hooks and the docker bridge connect to the raw port directly
(bypassing the proxy), so Codeman also keeps answering at the un-prefixed paths on the port
itself. Nothing about those flows changes.
- **Web-tab (dashboard) proxying** is base-path aware: proxied dashboards have their injected
`<base>` tag, root-absolute asset rewrites, runtime `fetch`/XHR shim, `Set-Cookie` paths, and
redirects all rebased onto the mount, so they load the same under `--base-url` as at the root.
## Session cookies and rate limits
The first request prompts for HTTP Basic credentials. On success the server issues an opaque
@@ -198,6 +239,7 @@ for the full guide.
| Symptom | Cause and fix |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `403 host not allowed` | Your domain is not in the allowlist. Set `CODEMAN_ALLOWED_HOSTS`. |
| Assets 404 / blank page under a sub-path | Start Codeman with `--base-url /<prefix>` and have the proxy forward the prefix unchanged (don't strip it). |
| Phone shows the login page but the terminal never connects | The proxy is not forwarding WebSocket upgrades. |
| Browser warns about the certificate | Expected with `--https` and its self-signed certificate. Tailscale gives you a real one instead. |
| LAN IP does not respond, but a tunnel to the same box works | The server is bound to loopback. That is the default. A tunnel reaches it; a LAN browser cannot. |