mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
committed by
Michael M. Tiller
co-authored by
Claude Opus 4.8
parent
6f7add7ce4
commit
7e4914d991
@@ -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)
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
@@ -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'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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
@@ -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()">⬇</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()">⬇</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()">⬇</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()">⬇</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');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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' },
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user