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
+3
View File
@@ -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` | | Dev with TLS | `npx tsx src/index.ts web --https` |
| Override window title hostname | `npx tsx src/index.ts web --title-hostname <name>` (default: `os.hostname()` — `codeman:<name>` is used for tab title, title-flash, and OS desktop notification prefix) | | Override window title hostname | `npx tsx src/index.ts web --title-hostname <name>` (default: `os.hostname()` — `codeman:<name>` 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` | | 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` | | Continuous typecheck | `tsc --noEmit --watch` |
| Watch-mode test | `npm run test:watch -- test/<file>.test.ts` (runs the CI gate's config; pass a file to narrow it) | | Watch-mode test | `npm run test:watch -- test/<file>.test.ts` (runs the CI gate's config; pass a file to narrow it) |
| Test coverage | `npm run test:coverage` | | Test coverage | `npm run test:coverage` |
@@ -250,6 +251,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) **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 `<base href="/">` to the mount and injects `window.__CODEMAN_BASE__` — the template's asset refs are all RELATIVE so `<base>` handles them for free; **(frontend runtime URLs)** root-absolute URLs ignore `<base>`, so `CodemanBase.url()` (constants.js) is the route builder, applied transparently by a `fetch` wrapper and explicitly at the few EventSource/WebSocket/`window.open`/`<img|iframe|a>`-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 `<base>`, 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) **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 `<a>`. ⚠️ **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) **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 `<a>`. ⚠️ **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)
+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 | | `CODEMAN_PASSWORD` (+ `CODEMAN_USERNAME`) | Enable HTTP Basic auth |
| `--host` / `CODEMAN_HOST` | Bind host (default `127.0.0.1`) | | `--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 | | `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) | | `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledge an unauthenticated non‑loopback bind (downgrades the warning) |
| `--https` | Enable TLS (adds HSTS) | | `--https` | Enable TLS (adds HSTS) |
| `CODEMAN_INSTANCE` | Scope tmux socket + data dir for isolation | | `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 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. 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 ## Session cookies and rate limits
The first request prompts for HTTP Basic credentials. On success the server issues an opaque 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 | | Symptom | Cause and fix |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `403 host not allowed` | Your domain is not in the allowlist. Set `CODEMAN_ALLOWED_HOSTS`. | | `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. | | 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. | | 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. | | 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. |
+24 -2
View File
@@ -16,6 +16,7 @@ import { isAbsolute, join } from 'node:path';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { dataPath } from './config/instance.js'; import { dataPath } from './config/instance.js';
import { casePath } from './config/cases-dir.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 { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js';
import { getSessionManager } from './session-manager.js'; import { getSessionManager } from './session-manager.js';
import { getTaskQueue } from './task-queue.js'; import { getTaskQueue } from './task-queue.js';
@@ -843,6 +844,11 @@ function addWebLaunchOptions(cmd: Command): Command {
.option('-H, --host <host>', 'Host to bind to', process.env.CODEMAN_HOST || '127.0.0.1') .option('-H, --host <host>', 'Host to bind to', process.env.CODEMAN_HOST || '127.0.0.1')
.option('-p, --port <port>', 'Port to listen on (env: CODEMAN_PORT)', process.env.CODEMAN_PORT || '3000') .option('-p, --port <port>', 'Port to listen on (env: CODEMAN_PORT)', process.env.CODEMAN_PORT || '3000')
.option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)') .option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)')
.option(
'--base-url <path>',
'Sub-path Codeman is mounted under behind a reverse proxy, e.g. /codeman (env: CODEMAN_BASE_URL)',
process.env.CODEMAN_BASE_URL || '/'
)
.option('--title-hostname <hostname>', 'Override the hostname shown in the browser title') .option('--title-hostname <hostname>', 'Override the hostname shown in the browser title')
.option( .option(
'--allow-unauthenticated-network', '--allow-unauthenticated-network',
@@ -859,6 +865,7 @@ function toWebLaunchOptions(options: {
host: string; host: string;
port: string; port: string;
https?: boolean; https?: boolean;
baseUrl?: string;
titleHostname?: string; titleHostname?: string;
allowUnauthenticatedNetwork?: boolean; allowUnauthenticatedNetwork?: boolean;
multiuser?: boolean; multiuser?: boolean;
@@ -868,10 +875,18 @@ function toWebLaunchOptions(options: {
console.error(palette.err(`✗ Invalid port: ${options.port}`)); console.error(palette.err(`✗ Invalid port: ${options.port}`));
process.exit(1); 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 { return {
host: options.host, host: options.host,
port, port,
https: !!options.https, https: !!options.https,
basePath,
titleHostname: options.titleHostname, titleHostname: options.titleHostname,
allowUnauthenticatedNetwork: !!options.allowUnauthenticatedNetwork, allowUnauthenticatedNetwork: !!options.allowUnauthenticatedNetwork,
multiuser: !!options.multiuser, multiuser: !!options.multiuser,
@@ -961,14 +976,21 @@ webCmd.action(async (options) => {
const https = launch.https; const https = launch.https;
const titleHostname = options.titleHostname; const titleHostname = options.titleHostname;
const allowUnauthenticatedNetwork = launch.allowUnauthenticatedNetwork ?? false; 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; 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 { try {
// The server prints its own "running at" line (it also covers the daemon and // 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. // 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) { if (https) {
console.log(palette.warn(' Note: Accept the self-signed certificate in your browser on first visit')); console.log(palette.warn(' Note: Accept the self-signed certificate in your browser on first visit'));
} }
+101
View File
@@ -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 <base>
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;
}
+3
View File
@@ -45,6 +45,8 @@ export interface WebLaunchOptions {
host: string; host: string;
port: number; port: number;
https: boolean; https: boolean;
/** Reverse-proxy sub-path prefix (normalized: '' for root, or '/foo'). */
basePath?: string;
titleHostname?: string; titleHostname?: string;
allowUnauthenticatedNetwork?: boolean; allowUnauthenticatedNetwork?: boolean;
multiuser?: boolean; multiuser?: boolean;
@@ -87,6 +89,7 @@ export interface DaemonStatus {
export function buildWebArgs(options: WebLaunchOptions): string[] { export function buildWebArgs(options: WebLaunchOptions): string[] {
const args = ['web', '--host', options.host, '--port', String(options.port)]; const args = ['web', '--host', options.host, '--port', String(options.port)];
if (options.https) args.push('--https'); 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.titleHostname) args.push('--title-hostname', options.titleHostname);
if (options.allowUnauthenticatedNetwork) args.push('--allow-unauthenticated-network'); if (options.allowUnauthenticatedNetwork) args.push('--allow-unauthenticated-network');
if (options.multiuser) args.push('--multiuser'); if (options.multiuser) args.push('--multiuser');
+17 -11
View File
@@ -142,7 +142,9 @@ function isPasswordChangeExempt(req: FastifyRequest): boolean {
* match the prefix at all. The Host allowlist is NOT bypassed, so DNS-rebinding * match the prefix at all. The Host allowlist is NOT bypassed, so DNS-rebinding
* protection still applies to these requests. * 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 url = (req.url ?? '').split('?')[0];
const fromPath = capabilityFromProxyPath(url); const fromPath = capabilityFromProxyPath(url);
@@ -167,7 +169,10 @@ function hasValidWebviewCapability(req: FastifyRequest): boolean {
// class the 404 relay could never rescue. See matchesRegisteredRoute. // class the 404 relay could never rescue. See matchesRegisteredRoute.
if (matchesRegisteredRoute(req, url)) return false; 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; 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) * @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 = { const state: AuthState = {
authSessions: null, authSessions: null,
authFailures: null, authFailures: null,
@@ -270,7 +275,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
ttlMs: AUTH_FAILURE_WINDOW_MS, ttlMs: AUTH_FAILURE_WINDOW_MS,
refreshOnGet: false, refreshOnGet: false,
}); });
registerMultiUserAuthHook(app, https, authSessions, authFailures, hookSecretFailures, state.userFailures); registerMultiUserAuthHook(app, https, authSessions, authFailures, hookSecretFailures, state.userFailures, basePath);
return state; 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. // Web-tab proxy, authenticated by the capability in the path, not the cookie.
if (hasValidWebviewCapability(req)) { if (hasValidWebviewCapability(req, basePath)) {
done(); done();
return; return;
} }
@@ -381,7 +386,8 @@ function registerMultiUserAuthHook(
authSessions: StaleExpirationMap<string, AuthSessionRecord>, authSessions: StaleExpirationMap<string, AuthSessionRecord>,
authFailures: StaleExpirationMap<string, number>, authFailures: StaleExpirationMap<string, number>,
hookSecretFailures: StaleExpirationMap<string, number>, hookSecretFailures: StaleExpirationMap<string, number>,
userFailures: StaleExpirationMap<string, number> userFailures: StaleExpirationMap<string, number>,
basePath = ''
): void { ): void {
const setSessionCookie = (reply: FastifyReply, token: string) => const setSessionCookie = (reply: FastifyReply, token: string) =>
reply.setCookie(AUTH_COOKIE_NAME, token, { reply.setCookie(AUTH_COOKIE_NAME, token, {
@@ -432,7 +438,7 @@ function registerMultiUserAuthHook(
// `req.authUser` stays undefined here on purpose: the proxy handler enforces // `req.authUser` stays undefined here on purpose: the proxy handler enforces
// ownership against the identity BOUND TO THE CAPABILITY, which is stricter // ownership against the identity BOUND TO THE CAPABILITY, which is stricter
// than re-deriving it from a request that carries no credentials. // than re-deriving it from a request that carries no credentials.
if (hasValidWebviewCapability(req)) return; if (hasValidWebviewCapability(req, basePath)) return;
const clientIp = req.ip; 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. * 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) => { app.addHook('onRequest', (req, reply, done) => {
const policy = getPolicy(); const policy = getPolicy();
if (!isAllowedRequestHost(req.headers.host, policy)) { if (!isAllowedRequestHost(req.headers.host, policy)) {
@@ -568,7 +574,7 @@ export function registerHostGuard(app: FastifyInstance, getPolicy: () => HostPol
if ( if (
!SAFE_HTTP_METHODS.has(req.method) && !SAFE_HTTP_METHODS.has(req.method) &&
!isAllowedRequestOrigin(req.headers.origin, policy) && !isAllowedRequestOrigin(req.headers.origin, policy) &&
!hasValidWebviewCapability(req) !hasValidWebviewCapability(req, basePath)
) { ) {
reply.code(403).send('Forbidden: cross-site request blocked'); reply.code(403).send('Forbidden: cross-site request blocked');
return; return;
@@ -580,7 +586,7 @@ export function registerHostGuard(app: FastifyInstance, getPolicy: () => HostPol
/** /**
* Register security headers and CORS middleware on every response. * 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 // Gesture-control overlay (opt-in via CODEMAN_GESTURE=1) runs MediaPipe, which
// needs WebAssembly eval (script-src) and blob workers (worker-src). Its wasm // needs WebAssembly eval (script-src) and blob workers (worker-src). Its wasm
// runtime + model are self-hosted under /gesture/ (same-origin, covered by // 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 // 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 // are not CORS-checked). Falling through lets the proxy route reply with the
// right headers. // right headers.
if (req.method === 'OPTIONS' && !hasValidWebviewCapability(req)) { if (req.method === 'OPTIONS' && !hasValidWebviewCapability(req, basePath)) {
reply.code(204).send(); reply.code(204).send();
done(); done();
return; return;
+8 -4
View File
@@ -1268,7 +1268,11 @@ class CodemanApp {
if (typeof window !== 'undefined' && typeof window.__CODEMAN_SOLO__ === 'string' && window.__CODEMAN_SOLO__) { if (typeof window !== 'undefined' && typeof window.__CODEMAN_SOLO__ === 'string' && window.__CODEMAN_SOLO__) {
return 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; return m ? decodeURIComponent(m[1]) : null;
} catch { return null; } } catch { return null; }
} }
@@ -1293,7 +1297,7 @@ class CodemanApp {
if (this.detachedSessions.has(id) && this._raiseDetached(id)) return; if (this.detachedSessions.has(id) && this._raiseDetached(id)) return;
const features = 'width=960,height=680,menubar=no,toolbar=no,location=no,status=no'; const features = 'width=960,height=680,menubar=no,toolbar=no,location=no,status=no';
let win = null; 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) { if (!win) {
this.showToast?.('Pop-out blocked — allow popups for this site to detach a session', 'error'); this.showToast?.('Pop-out blocked — allow popups for this site to detach a session', 'error');
return; return;
@@ -1545,7 +1549,7 @@ class CodemanApp {
// regardless of filter (server side). // regardless of filter (server side).
const _sseParams = new URLSearchParams({ clientId: this._clientId }); const _sseParams = new URLSearchParams({ clientId: this._clientId });
if (this.activeSessionId) _sseParams.set('sessions', this.activeSessionId); 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. // Store all event listeners for cleanup on reconnect.
// //
@@ -2753,7 +2757,7 @@ class CodemanApp {
// up to the limit). // up to the limit).
const cid = this._clientId ? `${this._clientId}:${this._wsTabNonce}` : ''; const cid = this._clientId ? `${this._clientId}:${this._wsTabNonce}` : '';
const cidQuery = cid ? `?cid=${encodeURIComponent(cid)}` : ''; 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); const ws = new WebSocket(url);
this._ws = ws; this._ws = ws;
this._wsSessionId = sessionId; this._wsSessionId = sessionId;
+57
View File
@@ -22,6 +22,63 @@
// Codeman — Shared constants and utility functions for frontend modules // 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 `<base href>` 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 `<base>` — 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 `<base>`), 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 // Web Push Utilities
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
+1 -1
View File
@@ -304,7 +304,7 @@ const PathPicker = {
// A hidden file is only reachable while the toggle is on, and the preview // 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. // endpoint re-resolves the path independently, so it needs the flag too.
if (this._showHidden) params.set('showHidden', 'true'); 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'); const overlay = document.createElement('div');
overlay.className = 'path-preview-overlay'; overlay.className = 'path-preview-overlay';
+2 -1
View File
@@ -2,7 +2,8 @@
"name": "Codeman", "name": "Codeman",
"short_name": "Codeman", "short_name": "Codeman",
"description": "Claude Code session manager", "description": "Claude Code session manager",
"start_url": "/", "start_url": "./",
"scope": "./",
"display": "standalone", "display": "standalone",
"orientation": "any", "orientation": "any",
"background_color": "#0a0a0a", "background_color": "#0a0a0a",
+1 -1
View File
@@ -390,7 +390,7 @@ class NotificationManager {
const notif = new Notification(`${this.originalTitle}: ${localizedTitle}`, { const notif = new Notification(`${this.originalTitle}: ${localizedTitle}`, {
body: localizedBody, body: localizedBody,
tag, // Groups same-tag notifications tag, // Groups same-tag notifications
icon: '/favicon.ico', icon: (window.CodemanBase?.url || ((p) => p))('/favicon.ico'),
silent: true, // We handle audio ourselves silent: true, // We handle audio ourselves
}); });
+39 -32
View File
@@ -3424,7 +3424,7 @@ Object.assign(CodemanApp.prototype, {
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name'; const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
const downloadBtn = !isDir const downloadBtn = !isDir
? `<a class="file-tree-download" href="${escapeHtml(`/api/sessions/${encodeURIComponent(owner)}/file-raw?path=${encodeURIComponent(node.path)}&download=true`)}" title="Download" onclick="event.stopPropagation()">&#x2B07;</a>` ? `<a class="file-tree-download" href="${escapeHtml(CodemanBase.url(`/api/sessions/${encodeURIComponent(owner)}/file-raw?path=${encodeURIComponent(node.path)}&download=true`))}" title="Download" onclick="event.stopPropagation()">&#x2B07;</a>`
: ''; : '';
html.push(` html.push(`
@@ -3599,7 +3599,7 @@ Object.assign(CodemanApp.prototype, {
: ''; : '';
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name'; const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
const downloadBtn = !isDir const downloadBtn = !isDir
? `<a class="file-tree-download" href="${escapeHtml(`/api/sessions/${ownerPath}/file-raw?path=${encodeURIComponent(match.path)}&download=true`)}" title="Download" onclick="event.stopPropagation()">&#x2B07;</a>` ? `<a class="file-tree-download" href="${escapeHtml(CodemanBase.url(`/api/sessions/${ownerPath}/file-raw?path=${encodeURIComponent(match.path)}&download=true`))}" title="Download" onclick="event.stopPropagation()">&#x2B07;</a>`
: ''; : '';
return ` return `
<div class="file-tree-item" data-path="${escapeHtml(match.path)}" data-type="${escapeHtml(match.type)}" data-owner="${escapeHtml(ownerSessionId)}"> <div class="file-tree-item" data-path="${escapeHtml(match.path)}" data-type="${escapeHtml(match.type)}" data-owner="${escapeHtml(ownerSessionId)}">
@@ -3998,18 +3998,20 @@ Object.assign(CodemanApp.prototype, {
// (html/htm arrive as a download there by design — file-raw serves them // (html/htm arrive as a download there by design — file-raw serves them
// attachment-only so widening READ never widens RUN.) // attachment-only so widening READ never widens RUN.)
const officeDoc = ext === 'docx' || ext === 'pptx'; const officeDoc = ext === 'docx' || ext === 'pptx';
this.filePreviewDetachUrl = attachmentId this.filePreviewDetachUrl = CodemanBase.url(
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/${officeDoc ? 'preview' : 'raw'}` attachmentId
: officeDoc ? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/${officeDoc ? 'preview' : 'raw'}`
? `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}` : officeDoc
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`; ? `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`
);
if (detachBtn) detachBtn.hidden = false; if (detachBtn) detachBtn.hidden = false;
// Registered attachment: render straight from its by-id routes — images and // Registered attachment: render straight from its by-id routes — images and
// PDFs inline, Office docs via the server-converted PDF preview, text fetched // PDFs inline, Office docs via the server-converted PDF preview, text fetched
// raw. (Workspace-path previews fall through to the file-content endpoint.) // raw. (Workspace-path previews fall through to the file-content endpoint.)
if (attachmentId) { 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']); const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
// VIDEO/AUDIO mirror VIDEO_ATTACHMENT_EXTENSIONS/AUDIO_ATTACHMENT_EXTENSIONS // VIDEO/AUDIO mirror VIDEO_ATTACHMENT_EXTENSIONS/AUDIO_ATTACHMENT_EXTENSIONS
// (src/attachment-registry.ts, the single source); the frontend cannot import // (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. // to file-content below, which would dump the binary bytes as mojibake.
if (ext === 'docx' || ext === 'pptx') { if (ext === 'docx' || ext === 'pptx') {
footerEl.textContent = ext.toUpperCase(); 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 = `<iframe src="${escapeHtml(previewSrc)}" title="${escapeHtml(filePath)}"></iframe>`; bodyEl.innerHTML = `<iframe src="${escapeHtml(previewSrc)}" title="${escapeHtml(filePath)}"></iframe>`;
return; return;
} }
if (ext === 'pdf') { if (ext === 'pdf') {
footerEl.textContent = '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 = `<iframe src="${escapeHtml(rawSrc)}" title="${escapeHtml(filePath)}"></iframe>`; bodyEl.innerHTML = `<iframe src="${escapeHtml(rawSrc)}" title="${escapeHtml(filePath)}"></iframe>`;
return; return;
} }
@@ -4107,19 +4109,19 @@ Object.assign(CodemanApp.prototype, {
const data = result.data; const data = result.data;
if (data.type === 'image') { if (data.type === 'image') {
bodyEl.innerHTML = `<img src="${data.url}" alt="${escapeHtml(filePath)}">`; bodyEl.innerHTML = `<img src="${escapeHtml(CodemanBase.url(data.url))}" alt="${escapeHtml(filePath)}">`;
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`; footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'video') { } else if (data.type === 'video') {
// playsinline: iOS otherwise hijacks playback into its fullscreen // playsinline: iOS otherwise hijacks playback into its fullscreen
// player, which leaves the overlay behind it and its own close button // player, which leaves the overlay behind it and its own close button
// as the only way back. // as the only way back.
bodyEl.innerHTML = `<video src="${escapeHtml(data.url)}" controls autoplay playsinline preload="metadata"></video>`; bodyEl.innerHTML = `<video src="${escapeHtml(CodemanBase.url(data.url))}" controls autoplay playsinline preload="metadata"></video>`;
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`; footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'audio') { } else if (data.type === 'audio') {
bodyEl.innerHTML = `<audio src="${escapeHtml(data.url)}" controls autoplay preload="metadata"></audio>`; bodyEl.innerHTML = `<audio src="${escapeHtml(CodemanBase.url(data.url))}" controls autoplay preload="metadata"></audio>`;
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`; footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'binary') { } 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 = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview<br><a href="${escapeHtml(downloadHref)}" download>Download</a></div>`; bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview<br><a href="${escapeHtml(downloadHref)}" download>Download</a></div>`;
footerEl.textContent = data.extension || 'binary'; footerEl.textContent = data.extension || 'binary';
} else { } else {
@@ -4416,9 +4418,11 @@ Object.assign(CodemanApp.prototype, {
}, },
openAttachmentInNewTab(sessionId, filePath, attachmentId = null) { openAttachmentInNewTab(sessionId, filePath, attachmentId = null) {
const url = attachmentId const url = CodemanBase.url(
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw` attachmentId
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`; ? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`
);
window.open(url, '_blank'); window.open(url, '_blank');
}, },
@@ -4454,19 +4458,22 @@ Object.assign(CodemanApp.prototype, {
const stack = this.ensureAttachmentCardStack(); const stack = this.ensureAttachmentCardStack();
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
const sessionName = session?.name || sessionId.substring(0, 8); const sessionName = session?.name || sessionId.substring(0, 8);
const attachmentRawUrl = const attachmentRawUrl = CodemanBase.url(
rawUrl || rawUrl ||
(attachmentId (attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw` ? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`); : `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`)
const attachmentPreviewUrl = );
const attachmentPreviewUrl = CodemanBase.url(
previewUrl || previewUrl ||
(attachmentId ? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/preview` : null); (attachmentId ? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/preview` : null)
const attachmentThumbnailUrl = );
const attachmentThumbnailUrl = CodemanBase.url(
thumbnailUrl || thumbnailUrl ||
(attachmentId (attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/thumbnail` ? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/thumbnail`
: `/api/sessions/${sessionId}/file-thumbnail?path=${encodeURIComponent(filePath)}`); : `/api/sessions/${sessionId}/file-thumbnail?path=${encodeURIComponent(filePath)}`)
);
const downloadUrl = attachmentId ? `${attachmentRawUrl}?download=true` : `${attachmentRawUrl}&download=true`; const downloadUrl = attachmentId ? `${attachmentRawUrl}?download=true` : `${attachmentRawUrl}&download=true`;
const typeLabel = (extension || attachmentType || 'file').toUpperCase(); const typeLabel = (extension || attachmentType || 'file').toUpperCase();
@@ -4730,7 +4737,7 @@ Object.assign(CodemanApp.prototype, {
.join(' • '); .join(' • ');
const thumb = const thumb =
item.thumbnailUrl && !item.missing item.thumbnailUrl && !item.missing
? `<img class="attachment-history-thumb-img" src="${escapeHtml(item.thumbnailUrl)}" alt="">` ? `<img class="attachment-history-thumb-img" src="${escapeHtml(CodemanBase.url(item.thumbnailUrl))}" alt="">`
: ''; : '';
const disabled = item.missing ? 'disabled aria-disabled="true"' : ''; const disabled = item.missing ? 'disabled aria-disabled="true"' : '';
return ` return `
@@ -4770,7 +4777,7 @@ Object.assign(CodemanApp.prototype, {
const item = this.getAttachmentHistoryItem(itemId); const item = this.getAttachmentHistoryItem(itemId);
if (!item || item.missing) return; if (!item || item.missing) return;
if (item.rawUrl || item.url) { if (item.rawUrl || item.url) {
window.open(item.rawUrl || item.url, '_blank'); window.open(CodemanBase.url(item.rawUrl || item.url), '_blank');
return; return;
} }
this.openAttachmentInNewTab(item.sessionId, item.relativePath || item.fileName, item.attachmentId || null); this.openAttachmentInNewTab(item.sessionId, item.relativePath || item.fileName, item.attachmentId || null);
@@ -4779,7 +4786,7 @@ Object.assign(CodemanApp.prototype, {
downloadAttachmentHistoryItem(itemId) { downloadAttachmentHistoryItem(itemId) {
const item = this.getAttachmentHistoryItem(itemId); const item = this.getAttachmentHistoryItem(itemId);
if (!item || item.missing || !item.downloadUrl) return; if (!item || item.missing || !item.downloadUrl) return;
window.open(item.downloadUrl, '_blank'); window.open(CodemanBase.url(item.downloadUrl), '_blank');
}, },
reshowAttachmentCard(itemId) { reshowAttachmentCard(itemId) {
@@ -4925,7 +4932,7 @@ Object.assign(CodemanApp.prototype, {
// Connect to SSE stream // Connect to SSE stream
const eventSource = new EventSource( 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) => { eventSource.onmessage = (e) => {
@@ -5067,7 +5074,7 @@ Object.assign(CodemanApp.prototype, {
// Build image URL using the existing file-raw endpoint // Build image URL using the existing file-raw endpoint
// Use relativePath (path from working dir) instead of fileName (basename) for subdirectory images // 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 // Create window element
const win = document.createElement('div'); const win = document.createElement('div');
+1 -1
View File
@@ -3046,7 +3046,7 @@ Object.assign(CodemanApp.prototype, {
return `<div class="case-manage-item" style="display:flex; align-items:center; gap:8px; justify-content:space-between;"> return `<div class="case-manage-item" style="display:flex; align-items:center; gap:8px; justify-content:space-between;">
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="${nm}">${nm} <span class="form-hint">(${mb} MB)</span></span> <span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="${nm}">${nm} <span class="form-hint">(${mb} MB)</span></span>
<span style="flex-shrink:0;"> <span style="flex-shrink:0;">
<a class="btn-toolbar" href="/api/docker-exports/${encodeURIComponent(e.name)}" download>Download</a> <a class="btn-toolbar" href="${CodemanBase.url(`/api/docker-exports/${encodeURIComponent(e.name)}`)}" download>Download</a>
<button class="btn-toolbar" onclick="app.importDockerBundle('${nm.replace(/'/g, "\\'")}')">Import</button> <button class="btn-toolbar" onclick="app.importDockerBundle('${nm.replace(/'/g, "\\'")}')">Import</button>
<button class="btn-toolbar" onclick="app.deleteDockerExport('${nm.replace(/'/g, "\\'")}')">Delete</button> <button class="btn-toolbar" onclick="app.deleteDockerExport('${nm.replace(/'/g, "\\'")}')">Delete</button>
</span> </span>
+4 -1
View File
@@ -187,7 +187,10 @@ Object.assign(CodemanApp.prototype, {
registerServiceWorker() { registerServiceWorker() {
if (!('serviceWorker' in navigator)) return; if (!('serviceWorker' in navigator)) return;
navigator.serviceWorker.register('/sw.js').then((reg) => { // Behind a sub-path mount the worker is served at <base>/sw.js and controls
// <base>/ (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; this._swRegistration = reg;
// Listen for messages from service worker (notification clicks) // Listen for messages from service worker (notification clicks)
navigator.serviceWorker.addEventListener('message', (event) => { navigator.serviceWorker.addEventListener('message', (event) => {
+13 -6
View File
@@ -20,6 +20,13 @@
const CACHE_NAME = 'codeman-v1'; const CACHE_NAME = 'codeman-v1';
// Reverse-proxy base path: the worker is served at `<base>/sw.js`, so its own
// location tells us the mount prefix ('' at root, or '/codeman'). Every URL below
// is prefixed through B() so the cached shell, icons and API calls resolve under
// the mount instead of escaping to the origin root.
const SW_BASE = self.location.pathname.replace(/\/sw\.js$/, '');
const B = (p) => (p && p[0] === '/' ? SW_BASE + p : p);
// Core app shell -- cached on install for instant startup // Core app shell -- cached on install for instant startup
const APP_SHELL = [ const APP_SHELL = [
'/', '/',
@@ -45,7 +52,7 @@ const APP_SHELL = [
'/icon-192.png', '/icon-192.png',
'/icon-512.png', '/icon-512.png',
'/manifest.json', '/manifest.json',
]; ].map(B);
// --- Install: precache app shell --- // --- Install: precache app shell ---
@@ -116,9 +123,9 @@ self.addEventListener('push', (event) => {
const options = { const options = {
body: body || '', body: body || '',
tag: tag || 'codeman-default', tag: tag || 'codeman-default',
icon: '/icon-192.png', icon: B('/icon-192.png'),
badge: '/icon-192.png', badge: B('/icon-192.png'),
data: { sessionId, approvalId, url: sessionId ? `/?session=${sessionId}` : '/' }, data: { sessionId, approvalId, url: sessionId ? B(`/?session=${sessionId}`) : B('/') },
renotify: true, renotify: true,
requireInteraction: urgency === 'critical', requireInteraction: urgency === 'critical',
}; };
@@ -143,7 +150,7 @@ self.addEventListener('notificationclick', (event) => {
event.notification.close(); event.notification.close();
const { sessionId, approvalId, url } = event.notification.data || {}; const { sessionId, approvalId, url } = event.notification.data || {};
const targetUrl = url || '/'; const targetUrl = url || B('/');
const action = event.action || null; const action = event.action || null;
// Approve/Deny action buttons answer the Approvals Inbox item directly from // 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. // because a service worker fetch carries the worker's own (same) origin.
if ((action === 'approve' || action === 'deny') && approvalId) { if ((action === 'approve' || action === 'deny') && approvalId) {
event.waitUntil( event.waitUntil(
fetch(`/api/approvals/${encodeURIComponent(approvalId)}/answer`, { fetch(B(`/api/approvals/${encodeURIComponent(approvalId)}/answer`), {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
+1 -1
View File
@@ -322,7 +322,7 @@ const ClaudeVoiceProvider = {
if (opts.keyterms?.length) params.set('keyterms', opts.keyterms.join(',')); if (opts.keyterms?.length) params.set('keyterms', opts.keyterms.join(','));
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
try { 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) { } catch (err) {
this._onError?.('Failed to open voice stream: ' + err.message); this._onError?.('Failed to open voice stream: ' + err.message);
this._cleanup(); this._cleanup();
+3 -1
View File
@@ -182,7 +182,9 @@ Object.assign(CodemanApp.prototype, {
if (webview.trusted) sandbox.push('allow-same-origin'); if (webview.trusted) sandbox.push('allow-same-origin');
frame.setAttribute('sandbox', sandbox.join(' ')); frame.setAttribute('sandbox', sandbox.join(' '));
frame.setAttribute('referrerpolicy', 'no-referrer-when-downgrade'); frame.setAttribute('referrerpolicy', 'no-referrer-when-downgrade');
frame.src = src; // Proxied dashboards carry a root-absolute `/webview/<cap>/` 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'); const failure = document.createElement('div');
failure.className = 'webview-failure'; failure.className = 'webview-failure';
+37 -21
View File
@@ -102,14 +102,14 @@ function withWebviews<T>(fn: (list: Webview[]) => Promise<T> | T): Promise<T> {
return next; return next;
} }
export function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort): void { export function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort, basePath = ''): void {
registerCrudRoutes(app, ctx); registerCrudRoutes(app, ctx, basePath);
registerProxyRoutes(app); registerProxyRoutes(app, basePath);
} }
// ───────────────────────────── CRUD ───────────────────────────── // ───────────────────────────── 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) => { app.get('/api/webviews', async (req) => {
const user = getAuthUser(req); const user = getAuthUser(req);
const all = await readWebviews(configDir()); 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 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 }; return { success: true, data };
}); });
} }
@@ -347,7 +347,7 @@ async function probeUrl(url: string): Promise<WebviewProbe> {
// ───────────────────────────── Proxy ───────────────────────────── // ───────────────────────────── Proxy ─────────────────────────────
function registerProxyRoutes(app: FastifyInstance): void { function registerProxyRoutes(app: FastifyInstance, basePath: string): void {
app.register(async (scope) => { app.register(async (scope) => {
// Encapsulated to this plugin only. The proxy must relay request bodies // 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 // 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, // A single GET route serving both roles: `handler` for normal requests,
// `wsHandler` for upgrades. Registering them as two routes on one URL would // `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 }>({ scope.route<{ Params: ProxyParams }>({
method: 'GET', method: 'GET',
url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`, url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`,
handler: proxyHttp, handler: (req, reply) => proxyHttp(req, reply, basePath),
wsHandler: proxyWebSocket, wsHandler: proxyWebSocket,
}); });
@@ -371,14 +371,14 @@ function registerProxyRoutes(app: FastifyInstance): void {
scope.route<{ Params: ProxyParams }>({ scope.route<{ Params: ProxyParams }>({
method: ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], method: ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`, url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`,
handler: proxyHttp, handler: (req, reply) => proxyHttp(req, reply, basePath),
}); });
// `/webview/<cap>` with no trailing slash: redirect rather than serve, so the // `/webview/<cap>` with no trailing slash: redirect rather than serve, so the
// browser's notion of the base path ends in `/` and relative URLs in 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. // dashboard's HTML resolve inside the prefix instead of one level above it.
scope.get<{ Params: { cap: string } }>(`${WEBVIEW_PROXY_PREFIX}/:cap`, (req, reply) => { 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<Webview | null> {
* streamed asset comes back zero-length. Returning the reply is what tells Fastify * streamed asset comes back zero-length. Returning the reply is what tells Fastify
* the response is already owned by this handler. * the response is already owned by this handler.
*/ */
function proxyHttp(req: FastifyRequest<{ Params: ProxyParams }>, reply: FastifyReply): Promise<FastifyReply> { function proxyHttp(
return proxyRequest(req, reply, req.params.cap, req.params['*'] ?? ''); req: FastifyRequest<{ Params: ProxyParams }>,
reply: FastifyReply,
basePath: string
): Promise<FastifyReply> {
return proxyRequest(req, reply, req.params.cap, req.params['*'] ?? '', basePath);
} }
/** /**
@@ -419,7 +423,8 @@ async function proxyRequest(
req: FastifyRequest, req: FastifyRequest,
reply: FastifyReply, reply: FastifyReply,
cap: string, cap: string,
wildcard: string wildcard: string,
basePath = ''
): Promise<FastifyReply> { ): Promise<FastifyReply> {
const webview = await lookupCapability(cap); const webview = await lookupCapability(cap);
if (!webview) { if (!webview) {
@@ -454,7 +459,8 @@ async function proxyRequest(
const headers = buildUpstreamRequestHeaders(req.headers, upstream, { const headers = buildUpstreamRequestHeaders(req.headers, upstream, {
forwardCookies: webview.trusted, forwardCookies: webview.trusted,
sessionCookieName: AUTH_COOKIE_NAME, 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 // #237: the timeout bounds TIME-TO-HEADERS only. A plain AbortSignal.timeout on
@@ -546,7 +552,8 @@ async function proxyRequest(
response.headers.getSetCookie(), response.headers.getSetCookie(),
cap, cap,
upstream, upstream,
secureContext secureContext,
basePath
); );
reply.code(response.status); reply.code(response.status);
@@ -575,7 +582,7 @@ async function proxyRequest(
// Buffer only HTML, only under the cap: `<base>` injection needs the whole // Buffer only HTML, only under the cap: `<base>` injection needs the whole
// document, and buffering an unbounded upstream body is a memory hazard. // document, and buffering an unbounded upstream body is a memory hazard.
const html = await response.text(); 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<typeof Readable.fromWeb>[0])); return reply.send(Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]));
@@ -596,25 +603,34 @@ async function proxyRequest(
* *
* @returns true when the request was handled (caller must not also reply). * @returns true when the request was handled (caller must not also reply).
*/ */
export async function tryWebviewRefererFallback(req: FastifyRequest, reply: FastifyReply): Promise<boolean> { export async function tryWebviewRefererFallback(
req: FastifyRequest,
reply: FastifyReply,
basePath = ''
): Promise<boolean> {
// Safe methods only. A write arriving here has already lost its raw body to the // 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. // root instance's JSON parser, so it could not be relayed faithfully anyway.
if (req.method !== 'GET' && req.method !== 'HEAD') return false; 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 (!capability) return false;
if (!webviewCapabilities.resolve(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(/^\//, ''); const path = req.url.split('?')[0].replace(/^\//, '');
await proxyRequest(req, reply, capability, path); await proxyRequest(req, reply, capability, path, basePath);
return true; return true;
} }
/** Turn a proxy-side Referer back into the upstream path it corresponds to. */ /** 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 { try {
const url = new URL(referer); const url = new URL(referer);
const prefix = proxyPrefixFor(capability); const prefix = proxyPrefixFor(capability, basePath);
if (!url.pathname.startsWith(prefix)) return undefined; if (!url.pathname.startsWith(prefix)) return undefined;
return `/${url.pathname.slice(prefix.length)}${url.search}`; return `/${url.pathname.slice(prefix.length)}${url.search}`;
} catch { } catch {
+65 -14
View File
@@ -41,6 +41,7 @@ import fs from 'node:fs/promises';
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { hostname as getHostname } from 'node:os'; import { hostname as getHostname } from 'node:os';
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js'; 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 { GLYPH, palette } from '../cli-style.js';
import { getHookSecret } from '../config/hook-secret.js'; import { getHookSecret } from '../config/hook-secret.js';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
@@ -260,6 +261,8 @@ export class WebServer extends EventEmitter {
private port: number; private port: number;
private host: string; private host: string;
private https: boolean; private https: boolean;
/** Reverse-proxy sub-path prefix (normalized: '' for root, or '/foo'). */
private basePath: string;
private testMode: boolean; private testMode: boolean;
private mux: TerminalMultiplexer; private mux: TerminalMultiplexer;
// Centralized cleanup for standalone timers (intervals + resettable timeouts) // Centralized cleanup for standalone timers (intervals + resettable timeouts)
@@ -330,13 +333,16 @@ export class WebServer extends EventEmitter {
testMode: boolean = false, testMode: boolean = false,
host: string = '127.0.0.1', host: string = '127.0.0.1',
titleHostname?: string, titleHostname?: string,
allowUnauthenticatedNetwork: boolean = false allowUnauthenticatedNetwork: boolean = false,
basePath: string = ''
) { ) {
super(); super();
this.setMaxListeners(0); this.setMaxListeners(0);
this.host = host; this.host = host;
this.port = port; this.port = port;
this.https = https; 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.testMode = testMode;
this.allowUnauthenticatedNetwork = this.allowUnauthenticatedNetwork =
allowUnauthenticatedNetwork || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK); allowUnauthenticatedNetwork || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK);
@@ -344,7 +350,12 @@ export class WebServer extends EventEmitter {
this.windowTitle = `codeman:${this.titleHostname}`; this.windowTitle = `codeman:${this.titleHostname}`;
this.indexHtmlTemplate = readFileSync(join(__dirname, 'public', 'index.html'), 'utf-8'); 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) { if (https) {
const { key, cert } = getOrCreateSelfSignedCert(); const { key, cert } = getOrCreateSelfSignedCert();
this.app = Fastify({ logger: false, https: { key, cert }, rewriteUrl }); 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) // Cookie plugin (needed for auth session tokens)
await this.app.register(fastifyCookie); 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): // Uniform response envelope (stable HTTP contract — docs/api-reference.md):
// wrap bare JSON payloads as { success:true, data } and map { success:false } // wrap bare JSON payloads as { success:true, data } and map { success:false }
// error envelopes to a conventional HTTP status (instead of 200). Skips // 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 // Anti-DNS-rebinding Host allowlist + cross-site (CSRF) Origin guard. Registered
// before auth so forged cross-site / rebound requests are rejected up front, even // 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. // 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) // 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) { if (authState) {
this.authSessions = authState.authSessions; this.authSessions = authState.authSessions;
this.authFailures = authState.authFailures; this.authFailures = authState.authFailures;
@@ -777,7 +803,7 @@ export class WebServer extends EventEmitter {
}); });
// Security headers + CORS // Security headers + CORS
registerSecurityHeaders(this.app, this.https); registerSecurityHeaders(this.app, this.https, this.basePath);
this.app.get('/', async (_req, reply) => { this.app.get('/', async (_req, reply) => {
return reply return reply
.header('Cache-Control', 'no-cache') .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, // rescue. Reaching this handler at all already proves no Codeman route matched,
// and the relay declines unless the Referer carries a live capability, so // and the relay declines unless the Referer carries a live capability, so
// genuinely unknown `/api` paths still get the envelope below. // 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')) { if (req.url.startsWith('/api')) {
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, notFound)); return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, notFound));
} }
@@ -1023,7 +1049,7 @@ export class WebServer extends EventEmitter {
registerMeRoutes(this.app, ctx); registerMeRoutes(this.app, ctx);
registerAdminRoutes(this.app, ctx); registerAdminRoutes(this.app, ctx);
registerOrchestratorRoutes(this.app, ctx); registerOrchestratorRoutes(this.app, ctx);
registerWebviewRoutes(this.app, ctx); registerWebviewRoutes(this.app, ctx, this.basePath);
registerTabLayoutRoutes(this.app, ctx); registerTabLayoutRoutes(this.app, ctx);
// Cron: build the service from the same context, recompute // Cron: build the service from the same context, recompute
@@ -1383,6 +1409,20 @@ export class WebServer extends EventEmitter {
'<title>Codeman</title>', '<title>Codeman</title>',
`<title>${escapeHtmlText(this.windowTitle)}</title>` `<title>${escapeHtmlText(this.windowTitle)}</title>`
); );
// Reverse-proxy sub-path support. The template ships `<base href="/">` 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 `<base>` cannot touch. Injected right after `<base>`
// 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(
'<base href="/">',
`<base href="${escapeHtmlText(this.basePath + '/')}">\n <script>window.__CODEMAN_BASE__=${JSON.stringify(this.basePath)};</script>`
);
}
// Cache-bust same-origin module scripts + stylesheets so a normal reload // Cache-bust same-origin module scripts + stylesheets so a normal reload
// always serves the latest (static assets carry a 1-year immutable cache). // always serves the latest (static assets carry a 1-year immutable cache).
html = this.cacheBustAssets(html); html = this.cacheBustAssets(html);
@@ -1492,10 +1532,9 @@ export class WebServer extends EventEmitter {
html = html.replace('</head>', `<script>window.__codemanGestureAvailable=true;</script>\n</head>`); html = html.replace('</head>', `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
if (settings.gestureControlEnabled === true) { if (settings.gestureControlEnabled === true) {
const v = this.gestureBundleVersion(); const v = this.gestureBundleVersion();
html = html.replace( // Relative src so the injected `<base href>` resolves it under the mount
'</head>', // prefix (a root-absolute `/gesture/...` would escape a sub-path mount).
`<script type="module" src="/gesture/gesture-codeman.js${v}"></script>\n</head>` html = html.replace('</head>', `<script type="module" src="gesture/gesture-codeman.js${v}"></script>\n</head>`);
);
} }
} }
return html; return html;
@@ -2490,7 +2529,18 @@ export class WebServer extends EventEmitter {
const displayHost = this.host === '0.0.0.0' ? 'localhost' : this.host; 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 // 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. // 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 // 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 // in-container hooks (permission/idle/stop callbacks) can reach a loopback-bound
@@ -3335,9 +3385,10 @@ export async function startWebServer(
testMode: boolean = false, testMode: boolean = false,
host: string = '127.0.0.1', host: string = '127.0.0.1',
titleHostname?: string, titleHostname?: string,
allowUnauthenticatedNetwork: boolean = false allowUnauthenticatedNetwork: boolean = false,
basePath: string = ''
): Promise<WebServer> { ): Promise<WebServer> {
const server = new WebServer(port, https, testMode, host, titleHostname, allowUnauthenticatedNetwork); const server = new WebServer(port, https, testMode, host, titleHostname, allowUnauthenticatedNetwork, basePath);
await server.start(); await server.start();
return server; return server;
} }
+29 -15
View File
@@ -39,6 +39,7 @@
*/ */
import { WEBVIEW_PROXY_PREFIX } from '../config/webview-limits.js'; 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. */ /** Headers that are per-connection and must never be relayed in either direction. */
const HOP_BY_HOP = new Set([ const HOP_BY_HOP = new Set([
@@ -99,9 +100,18 @@ const DROP_RESPONSE_HEADERS = new Set([
'referrer-policy', 'referrer-policy',
]); ]);
/** The same-origin path prefix an iframe loads for a given capability. */ /**
export function proxyPrefixFor(capability: string): string { * The same-origin path prefix an iframe loads for a given capability.
return `${WEBVIEW_PROXY_PREFIX}/${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
* `<base href>`, 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 * Extract the capability a `Referer` belongs to. Backs the 404 fallback that
* catches root-absolute asset requests (`/static/app.js`) which `<base>` cannot fix. * catches root-absolute asset requests (`/static/app.js`) which `<base>` cannot fix.
*/ */
export function capabilityFromReferer(referer: string | undefined): string | null { export function capabilityFromReferer(referer: string | undefined, basePath = ''): string | null {
if (!referer) return null; if (!referer) return null;
try { try {
return capabilityFromProxyPath(new URL(referer).pathname); // The Referer is browser-supplied, so under a sub-path mount it carries the
// prefix (`/foo/webview/<cap>/...`); strip it back to the internal path the
// capability parser expects.
return capabilityFromProxyPath(stripBasePath(basePath, new URL(referer).pathname));
} catch { } catch {
return null; 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 * proxied: relaying them would turn this into an open proxy for any host the
* upstream chooses to name. * 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; let resolved: URL;
try { try {
resolved = new URL(location, requestUrl); 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; if (resolved.origin !== requestUrl.origin) return location;
const suffix = resolved.pathname.replace(/^\//, ''); 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 * cookie name, and `Secure` is dropped when Codeman itself is serving plain HTTP
* in dev, where a Secure cookie would simply be discarded. * 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 parts = cookie.split(';');
const out: string[] = [parts[0]]; const out: string[] = [parts[0]];
let sawPath = false; let sawPath = false;
@@ -266,13 +279,13 @@ export function rewriteSetCookie(cookie: string, capability: string, secureConte
sawPath = true; sawPath = true;
const value = attr.slice('path='.length); const value = attr.slice('path='.length);
const suffix = value.replace(/^\//, ''); const suffix = value.replace(/^\//, '');
out.push(`Path=${proxyPrefixFor(capability)}${suffix}`); out.push(`Path=${proxyPrefixFor(capability, basePath)}${suffix}`);
continue; continue;
} }
out.push(attr); out.push(attr);
} }
if (!sawPath) out.push(`Path=${proxyPrefixFor(capability)}`); if (!sawPath) out.push(`Path=${proxyPrefixFor(capability, basePath)}`);
return out.join('; '); return out.join('; ');
} }
@@ -336,7 +349,8 @@ export function buildDownstreamResponseHeaders(
setCookies: string[], setCookies: string[],
capability: string, capability: string,
requestUrl: URL, requestUrl: URL,
secureContext: boolean secureContext: boolean,
basePath = ''
): { headers: Record<string, string>; setCookie: string[]; csp: string | null } { ): { headers: Record<string, string>; setCookie: string[]; csp: string | null } {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
let csp: string | null = null; let csp: string | null = null;
@@ -349,7 +363,7 @@ export function buildDownstreamResponseHeaders(
continue; continue;
} }
if (lower === 'location') { if (lower === 'location') {
headers['location'] = rewriteLocation(value, requestUrl, capability); headers['location'] = rewriteLocation(value, requestUrl, capability, basePath);
continue; continue;
} }
if (DROP_RESPONSE_HEADERS.has(lower)) 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. // override this; that is the dashboard author's own decision about their page.
headers['referrer-policy'] = 'same-origin'; 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 }; return { headers, setCookie, csp };
} }
@@ -593,8 +607,8 @@ try{
* relative URLs, attribute rewriting for root-absolute markup, and the shim for * relative URLs, attribute rewriting for root-absolute markup, and the shim for
* URLs built at runtime. * URLs built at runtime.
*/ */
export function rewriteHtml(html: string, capability: string): string { export function rewriteHtml(html: string, capability: string, basePath = ''): string {
const prefix = proxyPrefixFor(capability); const prefix = proxyPrefixFor(capability, basePath);
// Fresh regexes per call: module-level /g patterns carry `lastIndex` between calls. // Fresh regexes per call: module-level /g patterns carry `lastIndex` between calls.
const rebased = html const rebased = html
+119
View File
@@ -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);
}
});
});
+10
View File
@@ -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)', () => { 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 }); const args = buildWebArgs({ host: '127.0.0.1', port: 3000, https: false });
expect(args).not.toContain('--daemon'); expect(args).not.toContain('--daemon');
+4
View File
@@ -161,6 +161,8 @@ function loadPanel(options: { sessionId?: string | null; showHidden?: boolean }
CodemanApp, CodemanApp,
console, console,
escapeHtml, 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() }, localStorage: { getItem: () => null, setItem: vi.fn() },
document: { document: {
getElementById: (id: string) => elements[id] ?? null, getElementById: (id: string) => elements[id] ?? null,
@@ -222,6 +224,8 @@ function loadRealSelectSessionHarness(options: { terminalFailure?: boolean } = {
}, },
HTMLCanvasElement: class HTMLCanvasElement {}, HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: { OPEN: 1 }, 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 }, MobileDetection: { isTouchDevice: () => false },
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() }, localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
document: { document: {
+2
View File
@@ -38,6 +38,8 @@ function loadApp() {
console: { ...console, warn: vi.fn(), error: vi.fn() }, console: { ...console, warn: vi.fn(), error: vi.fn() },
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} }, localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
escapeHtml: (s: string) => String(s), 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() }, document: { getElementById: () => null, addEventListener: vi.fn() },
window: windowStub, window: windowStub,
setTimeout, setTimeout,
+2
View File
@@ -64,6 +64,8 @@ function loadApp(media: FakeMedia[]) {
console: { ...console, warn: vi.fn() }, console: { ...console, warn: vi.fn() },
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} }, localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
escapeHtml: (s: string) => String(s), 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() }, document: { getElementById: () => null, addEventListener: vi.fn() },
window: { addEventListener: vi.fn() }, window: { addEventListener: vi.fn() },
setTimeout, setTimeout,
+39
View File
@@ -228,3 +228,42 @@ describe('WebServer.renderIndexHtml', () => {
expect(html).not.toContain('gesture-codeman.js'); expect(html).not.toContain('gesture-codeman.js');
}); });
}); });
describe('WebServer.renderIndexHtml reverse-proxy base path', () => {
const BASE_TEMPLATE = ['<head>', '<base href="/">', '<title>Codeman</title>', '</head>', '<body></body>'].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('<base href="/">');
// 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('<base href="/codeman/">');
expect(html).toContain('window.__CODEMAN_BASE__="/codeman"');
// The global rides right after <base>, before any (deferred) script.
expect(html.indexOf('window.__CODEMAN_BASE__')).toBeLessThan(html.indexOf('</head>'));
});
it('normalizes a raw operator prefix passed to the constructor', async () => {
const server = makeBaseServer('codeman/');
const html = await render(server);
expect(html).toContain('<base href="/codeman/">');
expect(html).toContain('window.__CODEMAN_BASE__="/codeman"');
});
});
+36
View File
@@ -684,3 +684,39 @@ describe('referrer policy on proxied responses', () => {
expect(headers['referrer-policy']).toBe('same-origin'); 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();
});
});