diff --git a/CLAUDE.md b/CLAUDE.md index e0375930..1966b567 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,6 +99,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | Dev with TLS | `npx tsx src/index.ts web --https` | | Override window title hostname | `npx tsx src/index.ts web --title-hostname ` (default: `os.hostname()` — `codeman:` is used for tab title, title-flash, and OS desktop notification prefix) | | Bind a non-loopback host | `npx tsx src/index.ts web --host 0.0.0.0` (or `-H`; env `CODEMAN_HOST`; default `127.0.0.1`). Without `CODEMAN_PASSWORD` it **starts but warns loudly** — see Common Gotchas + `docs/security-architecture.md` | +| Mount under a reverse-proxy sub-path | `npx tsx src/index.ts web --base-url /codeman` (env `CODEMAN_BASE_URL`; default `/`). Normalized in `src/config/base-path.ts` (`''` = root). See Reverse-proxy base path below + `docs/wiki/Remote-Access.md` | | Continuous typecheck | `tsc --noEmit --watch` | | Watch-mode test | `npm run test:watch -- test/.test.ts` (runs the CI gate's config; pass a file to narrow it) | | Test coverage | `npm run test:coverage` | @@ -253,6 +254,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Self-update** (App Settings → System → Updates): in-app updater for git-clone installs supervised by systemd/launchd (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none` → "restart manually"). The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` that outlives the restart and writes progress to `update-status.json`, which the browser polls across the connection drop. `src/web/self-update.ts` splits pure helpers (unit-tested) from IO wrappers. npm installs report as non-updatable. ⚠️ **The Compose deployment is the one supervisor that does NOT outlive the restart**: there the restart IS the container exiting (`restart: unless-stopped` relaunches it), which kills the script too — safe only because the terminal `restarting` marker is written BEFORE the kill, so nothing may be appended after it. Two config facts make it work at all and both are load-bearing: the repo is a HOST BIND MOUNT over `/opt/codeman` (a pull into the baked image copy would land in the writable layer and be silently discarded by the next `up`), and the runtime image keeps devDependencies + a build toolchain (`npm run build` is tsc+esbuild, and node-pty has no Linux prebuild), which is why `npm prune --omit=dev` is gone and the updater passes `--include=dev` against `NODE_ENV=production`. ⚠️ An in-place container update applies CODE ONLY — a restart reuses the existing image and config — so `evaluateEnvironmentGate()` REFUSES a release that changes `server.Dockerfile`/`docker-compose.yaml` (sha256 vs the baseline `Start-Codeman.sh` writes to `docker-env-applied.json` on every start) or adds `.env.example` keys the user's `.env` lacks, and refuses when the restart policy would not bring the container back. That third check exists because **Compose resolves an unset `${VAR}` to the EMPTY STRING and starts anyway**, so a new required setting otherwise arrives as a silently blank env var. Every unknown fails OPEN in the gate (no baseline, unreadable `.env`, no socket): failing closed would permanently block containers created before the fingerprint file existed. ⚠️ The KILL does not: the server exits only when `--restart-by-exit 1` was passed, i.e. the Compose file declared `CODEMAN_RESTART_BY_EXIT=1` (set ONLY there, since that file is what sets `restart: unless-stopped`; the image ENV deliberately does not) or the daemon reported an auto-restart policy; otherwise the build lands as `completed-needs-manual-restart`, because exiting blind takes a `docker run` container with no restart policy down with no UI left to recover it. The gate is re-evaluated on `POST /api/system/update`, so hiding the button is UX, not the control. ⚠️ The four global agent CLIs in `server.Dockerfile` are PINNED on purpose — unpinned, a user's CLI versions are a function of when their image was built rather than of any commit, which is the one environment change no diff-derived gate can see; pinning turns it into a Dockerfile change the gate already catches. `test/docker-compose-env-parity.test.ts` is the merge-side guard (every compose `${VAR}` ↔ an `.env.example` entry). → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update) +**Reverse-proxy base path** (`--base-url` / `CODEMAN_BASE_URL`, default `/`; `src/config/base-path.ts` is the pure single-source, normalized to `''` for root or `/foo`): lets Codeman be mounted under a sub-path behind a proxy that **forwards the prefix unchanged** (does NOT strip it). Deliberately few choke points, mirrored ingress/egress: **(server ingress)** `stripBasePath()` runs inside Fastify's `rewriteUrl` so ALL routes stay declared prefix-agnostic (`/api/...`, `/ws/...`) — and a request arriving WITHOUT the prefix (hooks, health checks, docker bridge, all hitting the raw port) is left untouched, so the server answers at both; **(server egress)** one `onSend` hook prepends the base to every root-absolute `Location` header, covering all redirects; **(HTML)** `renderIndexHtml` rewrites the shipped `` to the mount and injects `window.__CODEMAN_BASE__` — the template's asset refs are all RELATIVE so `` handles them for free; **(frontend runtime URLs)** root-absolute URLs ignore ``, so `CodemanBase.url()` (constants.js) is the route builder, applied transparently by a `fetch` wrapper and explicitly at the few EventSource/WebSocket/`window.open`/``-src sites; **(sw.js/manifest)** the worker derives its base from `self.location`, the manifest uses relative `start_url`/`scope`; **(web-tab proxy)** `proxyPrefixFor(cap, basePath)` is the single base-aware root that cascades to the injected ``, root-absolute HTML rewrites, the `runtimeUrlShim`, `Set-Cookie` Path and `Location` rebasing — while the INGRESS parsers (`capabilityFromProxyPath`, `resolveUpstreamUrl`) stay base-agnostic because `rewriteUrl` strips the prefix before routing, and `capabilityFromReferer(referer, basePath)` strips it from the browser-supplied Referer. ⚠️ `--base-url` rides the daemon relaunch via `buildWebArgs` and the service unit via `resolveServicePlan`. Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test.ts`; HTML injection in `test/render-index-html.test.ts`. + **Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments) **File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND the response viewer's `_linkifyFilePaths()`; a fresh instance per call, since `lastIndex` is per-object state. The chat linkifier walks TEXT NODES with DOM APIs (the source is model output; never rebuild sanitized markup as a string) and skips subtrees already inside an ``. ⚠️ **An out-of-workspace path is served through the ATTACHMENT routes, not the file routes** — `file-content`/`file-raw` are workspace-confined and 404 exactly the paths agents print most (a `/tmp` capture, Claude's scratchpad), so `openFilePreview()` registers such a path via `POST /api/sessions/:id/attachments` with **`notify: false`** (suppresses only the `attachment:detected` broadcast — same guard, same routes; without it every click also popped a card announcing the file already on screen) and renders by id. The click is an explicit action on the explicit, Origin-guarded route, which is what distinguishes it from the force-confined magic-link scanner. ⚠️ **Media extensions are single-sourced** (`VIDEO_ATTACHMENT_EXTENSIONS`/`AUDIO_ATTACHMENT_EXTENSIONS` in `attachment-registry.ts`, imported by `file-content`'s classification) so a clip plays the same in or out of the workspace; a player needs all THREE of allowlist + a real `MIME_TYPES` entry (octet-stream renders a dead player) + the range-aware body. ⚠️ **`TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS`** (never a second list): if the viewer would edit it inside the workspace, it can be read outside. Widening READ must never widen RUN, so `html`/`htm` joined `svg` in `serveRawFile`'s download-only branch, other text goes out as inert `text/plain`+`nosniff`, and `~/.codeman*/state.json` joined `isSensitivePath` (it persists `envOverrides`, which can hold `GEMINI_API_KEY`). ⚠️ The terminal sends an **out-of-workspace** path to the preview instead of the log viewer (that one spawns `tail -f` and reaches only workspace + `/var/log` + `~/logs`); in-workspace text keeps the tail viewer and `file-stream-manager`'s allowlist is untouched. The image-watcher keeps its own narrow detection list, so none of this cards every file an agent writes. → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) diff --git a/docs/security-architecture.md b/docs/security-architecture.md index 5f2116ce..719caac3 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -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 | diff --git a/docs/wiki/Remote-Access.md b/docs/wiki/Remote-Access.md index e7e810c1..1a6a9e10 100644 --- a/docs/wiki/Remote-Access.md +++ b/docs/wiki/Remote-Access.md @@ -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 + `` 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 /` 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. | diff --git a/src/cli.ts b/src/cli.ts index 874d0d22..2da7fac7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 to bind to', process.env.CODEMAN_HOST || '127.0.0.1') .option('-p, --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 ', + '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 ', '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')); } diff --git a/src/config/base-path.ts b/src/config/base-path.ts new file mode 100644 index 00000000..0ac9d1ed --- /dev/null +++ b/src/config/base-path.ts @@ -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 + 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; +} diff --git a/src/daemon-control.ts b/src/daemon-control.ts index 52a6ac00..cb09f3d7 100644 --- a/src/daemon-control.ts +++ b/src/daemon-control.ts @@ -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'); diff --git a/src/web/middleware/auth.ts b/src/web/middleware/auth.ts index af0d0cfe..adbd9461 100644 --- a/src/web/middleware/auth.ts +++ b/src/web/middleware/auth.ts @@ -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, authFailures: StaleExpirationMap, hookSecretFailures: StaleExpirationMap, - userFailures: StaleExpirationMap + userFailures: StaleExpirationMap, + 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; diff --git a/src/web/public/app.js b/src/web/public/app.js index 0beff0d9..cb8bae78 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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; diff --git a/src/web/public/constants.js b/src/web/public/constants.js index d93888f9..0302807f 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -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 `` 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 `` — 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 ``), 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 // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index 7ec9a890..8cdc16ba 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -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'; diff --git a/src/web/public/manifest.json b/src/web/public/manifest.json index 79b6ea64..8ce935b0 100644 --- a/src/web/public/manifest.json +++ b/src/web/public/manifest.json @@ -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", diff --git a/src/web/public/notification-manager.js b/src/web/public/notification-manager.js index 2d7f4d9f..c1f7f665 100644 --- a/src/web/public/notification-manager.js +++ b/src/web/public/notification-manager.js @@ -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 }); diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 66197c27..482e7639 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -3424,7 +3424,7 @@ Object.assign(CodemanApp.prototype, { const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name'; const downloadBtn = !isDir - ? `⬇` + ? `⬇` : ''; html.push(` @@ -3599,7 +3599,7 @@ Object.assign(CodemanApp.prototype, { : ''; const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name'; const downloadBtn = !isDir - ? `⬇` + ? `⬇` : ''; return `
@@ -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 = ``; 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 = ``; return; } @@ -4107,19 +4109,19 @@ Object.assign(CodemanApp.prototype, { const data = result.data; if (data.type === 'image') { - bodyEl.innerHTML = `${escapeHtml(filePath)}`; + bodyEl.innerHTML = `${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 = ``; + bodyEl.innerHTML = ``; footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`; } else if (data.type === 'audio') { - bodyEl.innerHTML = ``; + bodyEl.innerHTML = ``; 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 = `
Binary file (${this.formatFileSize(data.size)})
Cannot preview
Download
`; 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 - ? `` + ? `` : ''; 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'); diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 23969153..c970b633 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -3364,7 +3364,7 @@ Object.assign(CodemanApp.prototype, { return `
${nm} (${mb} MB) - Download + Download diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index a346b479..75e088a6 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -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 /sw.js and controls + // / (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) => { diff --git a/src/web/public/sw.js b/src/web/public/sw.js index de45a218..efe17981 100644 --- a/src/web/public/sw.js +++ b/src/web/public/sw.js @@ -20,6 +20,13 @@ const CACHE_NAME = 'codeman-v1'; +// Reverse-proxy base path: the worker is served at `/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' }, diff --git a/src/web/public/voice-input.js b/src/web/public/voice-input.js index 28c22985..1c6355e9 100644 --- a/src/web/public/voice-input.js +++ b/src/web/public/voice-input.js @@ -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(); diff --git a/src/web/public/webview-tabs.js b/src/web/public/webview-tabs.js index 97c87a44..0e56365b 100644 --- a/src/web/public/webview-tabs.js +++ b/src/web/public/webview-tabs.js @@ -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//` 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'; diff --git a/src/web/routes/webview-routes.ts b/src/web/routes/webview-routes.ts index b39d247c..16204ba1 100644 --- a/src/web/routes/webview-routes.ts +++ b/src/web/routes/webview-routes.ts @@ -102,14 +102,14 @@ function withWebviews(fn: (list: Webview[]) => Promise | T): Promise { 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 { // ───────────────────────────── 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/` 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 { * 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 { - return proxyRequest(req, reply, req.params.cap, req.params['*'] ?? ''); +function proxyHttp( + req: FastifyRequest<{ Params: ProxyParams }>, + reply: FastifyReply, + basePath: string +): Promise { + 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 { 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: `` 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[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 { +export async function tryWebviewRefererFallback( + req: FastifyRequest, + reply: FastifyReply, + basePath = '' +): Promise { // 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 { diff --git a/src/web/server.ts b/src/web/server.ts index 2272aef5..305bd264 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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 { 'Codeman', `${escapeHtmlText(this.windowTitle)}` ); + // Reverse-proxy sub-path support. The template ships `` 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 `` cannot touch. Injected right after `` + // 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( + '', + `\n ` + ); + } // 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('', `\n`); if (settings.gestureControlEnabled === true) { const v = this.gestureBundleVersion(); - html = html.replace( - '', - `\n` - ); + // Relative src so the injected `` resolves it under the mount + // prefix (a root-absolute `/gesture/...` would escape a sub-path mount). + html = html.replace('', `\n`); } } 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 { - 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; } diff --git a/src/web/webview-proxy.ts b/src/web/webview-proxy.ts index 4856b34b..d94626f7 100644 --- a/src/web/webview-proxy.ts +++ b/src/web/webview-proxy.ts @@ -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 + * ``, 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 `` 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//...`); 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; setCookie: string[]; csp: string | null } { const headers: Record = {}; 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 diff --git a/test/base-path.test.ts b/test/base-path.test.ts new file mode 100644 index 00000000..22abb5ac --- /dev/null +++ b/test/base-path.test.ts @@ -0,0 +1,119 @@ +/** + * @fileoverview Unit tests for the pure reverse-proxy base-path helpers + * (src/config/base-path.ts). These back the server ingress strip (rewriteUrl), + * the egress Location rewrite (onSend), and the frontend route builder, so their + * correctness is what makes a sub-path mount work end to end. + */ +import { describe, it, expect } from 'vitest'; +import { + normalizeBasePath, + isValidBasePath, + assertValidBasePath, + joinBasePath, + stripBasePath, +} from '../src/config/base-path.js'; + +describe('normalizeBasePath', () => { + it('treats root / and empty as no prefix', () => { + expect(normalizeBasePath('/')).toBe(''); + expect(normalizeBasePath('')).toBe(''); + expect(normalizeBasePath(undefined)).toBe(''); + expect(normalizeBasePath(null)).toBe(''); + expect(normalizeBasePath(' ')).toBe(''); + }); + + it('adds a leading slash and drops trailing slashes', () => { + expect(normalizeBasePath('codeman')).toBe('/codeman'); + expect(normalizeBasePath('/codeman')).toBe('/codeman'); + expect(normalizeBasePath('/codeman/')).toBe('/codeman'); + expect(normalizeBasePath('codeman///')).toBe('/codeman'); + }); + + it('collapses duplicate slashes and keeps nested segments', () => { + expect(normalizeBasePath('//a//b//')).toBe('/a/b'); + expect(normalizeBasePath('/tools/codeman')).toBe('/tools/codeman'); + }); +}); + +describe('isValidBasePath / assertValidBasePath', () => { + it('accepts root and well-formed segments', () => { + expect(isValidBasePath('')).toBe(true); + expect(isValidBasePath('/codeman')).toBe(true); + expect(isValidBasePath('/tools/codeman-2')).toBe(true); + expect(isValidBasePath('/a_b.c~d')).toBe(true); + }); + + it('rejects segments with unsafe characters', () => { + expect(isValidBasePath('/a b')).toBe(false); + expect(isValidBasePath('/a?b')).toBe(false); + expect(isValidBasePath('/a#b')).toBe(false); + expect(isValidBasePath('/a%2f')).toBe(false); + }); + + it('assertValidBasePath normalizes valid input and throws on bad', () => { + expect(assertValidBasePath('/codeman/')).toBe('/codeman'); + expect(assertValidBasePath('/')).toBe(''); + expect(() => assertValidBasePath('/a b')).toThrow(/Invalid --base-url/); + expect(() => assertValidBasePath('?x')).toThrow(/Invalid --base-url/); + }); +}); + +describe('joinBasePath (frontend/egress route builder)', () => { + it('is a no-op at root', () => { + expect(joinBasePath('', '/api/x')).toBe('/api/x'); + expect(joinBasePath('', '/')).toBe('/'); + }); + + it('prefixes root-absolute app paths', () => { + expect(joinBasePath('/codeman', '/api/x')).toBe('/codeman/api/x'); + expect(joinBasePath('/codeman', '/')).toBe('/codeman/'); + expect(joinBasePath('/codeman', '/ws/sessions/1/terminal')).toBe('/codeman/ws/sessions/1/terminal'); + }); + + it('leaves absolute, protocol-relative, and relative URLs alone', () => { + expect(joinBasePath('/codeman', 'https://x/y')).toBe('https://x/y'); + expect(joinBasePath('/codeman', 'ws://x/y')).toBe('ws://x/y'); + expect(joinBasePath('/codeman', '//host/y')).toBe('//host/y'); + expect(joinBasePath('/codeman', 'app.js')).toBe('app.js'); + expect(joinBasePath('/codeman', '#frag')).toBe('#frag'); + expect(joinBasePath('/codeman', 'data:image/png;base64,AAAA')).toBe('data:image/png;base64,AAAA'); + }); + + it('is idempotent — never double-prefixes', () => { + expect(joinBasePath('/codeman', '/codeman/api/x')).toBe('/codeman/api/x'); + expect(joinBasePath('/codeman', '/codeman')).toBe('/codeman'); + expect(joinBasePath('/codeman', '/codeman?y=1')).toBe('/codeman?y=1'); + }); + + it('does not treat a same-named sibling path as already-prefixed', () => { + // /codeman-docs must NOT be mistaken for the /codeman mount. + expect(joinBasePath('/codeman', '/codeman-docs/x')).toBe('/codeman/codeman-docs/x'); + }); +}); + +describe('stripBasePath (server ingress)', () => { + it('is a no-op at root', () => { + expect(stripBasePath('', '/api/x')).toBe('/api/x'); + }); + + it('strips the prefix from proxied requests', () => { + expect(stripBasePath('/codeman', '/codeman/api/x')).toBe('/api/x'); + expect(stripBasePath('/codeman', '/codeman')).toBe('/'); + expect(stripBasePath('/codeman', '/codeman/')).toBe('/'); + expect(stripBasePath('/codeman', '/codeman?y=1')).toBe('/?y=1'); + }); + + it('leaves un-prefixed requests unchanged (direct-to-port: hooks, health, docker bridge)', () => { + expect(stripBasePath('/codeman', '/api/x')).toBe('/api/x'); + expect(stripBasePath('/codeman', '/api/hook-event')).toBe('/api/hook-event'); + // A same-named sibling is not the mount. + expect(stripBasePath('/codeman', '/codeman-docs/x')).toBe('/codeman-docs/x'); + }); + + it('round-trips with joinBasePath', () => { + const base = '/tools/codeman'; + for (const p of ['/', '/api/x', '/ws/y', '/session/abc']) { + expect(stripBasePath(base, joinBasePath(base, p))).toBe(p); + } + }); +}); diff --git a/test/daemon-control.test.ts b/test/daemon-control.test.ts index 10651337..a5bb642f 100644 --- a/test/daemon-control.test.ts +++ b/test/daemon-control.test.ts @@ -52,6 +52,16 @@ describe('buildWebArgs', () => { ]); }); + it('forwards --base-url so a detached/service relaunch keeps the mount prefix', () => { + const args = buildWebArgs({ host: '127.0.0.1', port: 3000, https: false, basePath: '/codeman' }); + expect(args).toContain('--base-url'); + expect(args[args.indexOf('--base-url') + 1]).toBe('/codeman'); + }); + + it('omits --base-url at root (empty basePath)', () => { + expect(buildWebArgs({ host: '127.0.0.1', port: 3000, https: false, basePath: '' })).not.toContain('--base-url'); + }); + it('never re-emits the daemon flags themselves (the child must not re-fork)', () => { const args = buildWebArgs({ host: '127.0.0.1', port: 3000, https: false }); expect(args).not.toContain('--daemon'); diff --git a/test/file-browser-search.test.ts b/test/file-browser-search.test.ts index 13e804b8..d6e95a58 100644 --- a/test/file-browser-search.test.ts +++ b/test/file-browser-search.test.ts @@ -161,6 +161,8 @@ function loadPanel(options: { sessionId?: string | null; showHidden?: boolean } CodemanApp, console, escapeHtml, + // Reverse-proxy route builder from constants.js (not loaded here); identity at root. + CodemanBase: { base: '', url: (p: string) => p }, localStorage: { getItem: () => null, setItem: vi.fn() }, document: { getElementById: (id: string) => elements[id] ?? null, @@ -222,6 +224,8 @@ function loadRealSelectSessionHarness(options: { terminalFailure?: boolean } = { }, HTMLCanvasElement: class HTMLCanvasElement {}, WebSocket: { OPEN: 1 }, + // Reverse-proxy route builder from constants.js (not loaded here); identity at root. + CodemanBase: { base: '', url: (p: string) => p }, MobileDetection: { isTouchDevice: () => false }, localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() }, document: { diff --git a/test/file-preview-detach.test.ts b/test/file-preview-detach.test.ts index 9e31df9c..6773386c 100644 --- a/test/file-preview-detach.test.ts +++ b/test/file-preview-detach.test.ts @@ -38,6 +38,8 @@ function loadApp() { console: { ...console, warn: vi.fn(), error: vi.fn() }, localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} }, escapeHtml: (s: string) => String(s), + // Reverse-proxy route builder from constants.js (not loaded here); identity at root. + CodemanBase: { base: '', url: (p: string) => p }, document: { getElementById: () => null, addEventListener: vi.fn() }, window: windowStub, setTimeout, diff --git a/test/file-preview-media.test.ts b/test/file-preview-media.test.ts index 1152ddf5..33af4591 100644 --- a/test/file-preview-media.test.ts +++ b/test/file-preview-media.test.ts @@ -64,6 +64,8 @@ function loadApp(media: FakeMedia[]) { console: { ...console, warn: vi.fn() }, localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} }, escapeHtml: (s: string) => String(s), + // Reverse-proxy route builder from constants.js (not loaded here); identity at root. + CodemanBase: { base: '', url: (p: string) => p }, document: { getElementById: () => null, addEventListener: vi.fn() }, window: { addEventListener: vi.fn() }, setTimeout, diff --git a/test/render-index-html.test.ts b/test/render-index-html.test.ts index 1d103508..0cb6b334 100644 --- a/test/render-index-html.test.ts +++ b/test/render-index-html.test.ts @@ -228,3 +228,42 @@ describe('WebServer.renderIndexHtml', () => { expect(html).not.toContain('gesture-codeman.js'); }); }); + +describe('WebServer.renderIndexHtml reverse-proxy base path', () => { + const BASE_TEMPLATE = ['', '', 'Codeman', '', ''].join('\n'); + + function makeBaseServer(basePath: string) { + // constructor: (port, https, testMode, host, titleHostname, allowUnauth, basePath) + const server = new WebServer(0, false, true, '127.0.0.1', undefined, false, basePath); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (server as any).indexHtmlTemplate = BASE_TEMPLATE; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (server as any).readSettings = vi.fn(async () => ({})); + return server; + } + + it('is inert at root — base tag unchanged and no base global injected', async () => { + const server = makeBaseServer(''); + const html = await render(server); + expect(html).toContain(''); + // At root the frontend reads a MISSING __CODEMAN_BASE__ as root, so nothing is + // injected and the historical output is byte-identical. + expect(html).not.toContain('__CODEMAN_BASE__'); + }); + + it('points the base tag and the base global at a sub-path mount', async () => { + const server = makeBaseServer('/codeman'); + const html = await render(server); + expect(html).toContain(''); + expect(html).toContain('window.__CODEMAN_BASE__="/codeman"'); + // The global rides right after , before any (deferred) script. + expect(html.indexOf('window.__CODEMAN_BASE__')).toBeLessThan(html.indexOf('')); + }); + + it('normalizes a raw operator prefix passed to the constructor', async () => { + const server = makeBaseServer('codeman/'); + const html = await render(server); + expect(html).toContain(''); + expect(html).toContain('window.__CODEMAN_BASE__="/codeman"'); + }); +}); diff --git a/test/webview-proxy.test.ts b/test/webview-proxy.test.ts index bd020f34..e4b4c52f 100644 --- a/test/webview-proxy.test.ts +++ b/test/webview-proxy.test.ts @@ -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('', CAP, BASE); + expect(out).toContain(``); + 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(); + }); +});