mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e063222ac2 | ||
|
|
346bc8b173 | ||
|
|
b34fcaf928 |
@@ -1,5 +1,24 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.8.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Web tabs: open dashboard URLs as tabs beside agent sessions, plus terminal link fixes.
|
||||
|
||||
**Web tabs.** The Run dropdown gains a "Web / URL" section. A saved URL renders as a tab in the same strip as Claude/Codex/Gemini sessions, with the same Alt+1-9 numbering, an icon picker, and per-device tab order. Frames stay mounted while hidden (LRU-bounded), so switching tabs never reloads a dashboard.
|
||||
|
||||
Dashboards are proxied through Codeman's own origin, because a direct iframe fails three ways at once: an HTTPS Codeman cannot embed a plain-HTTP target (mixed content, with no override at all on iOS Safari), many dashboards send `X-Frame-Options: DENY`, and Codeman's own `default-src 'self'` CSP blocks cross-origin frames. Proxying dissolves all three and leaves the production CSP unchanged. The fetch happens server-side, so a tailnet-only or localhost-only dashboard is reachable from any device that can reach Codeman.
|
||||
|
||||
The proxy is not an API surface: it authenticates on a 192-bit capability in the path (memory-only, rolling TTL, bound to the minting user, revoked on edit or delete) and is exempt from the cookie and Origin checks, because a sandboxed iframe is opaque-origin and sends neither. The Host allowlist is never bypassed. Iframes omit `allow-same-origin` unless a URL is explicitly marked trusted, and `Authorization` plus the session cookie are stripped upstream in both modes so `CODEMAN_PASSWORD` cannot leak into a dashboard. Includes an HTTP and WebSocket proxy, redirect/cookie/`<base>` rewriting, a runtime URL shim for requests built by dashboard JavaScript, and CORS handling for the opaque-origin frame. New endpoints under `/api/webviews`, storage in `~/.codeman/webviews.json`, user guide in `docs/web-tabs.md`.
|
||||
|
||||
**Terminal links no longer truncate.** Three separate cuts, each producing a link that opened the wrong target or none at all:
|
||||
- A single `&` ended the match, so every query string was cut. A WordPress edit link resolved to `?post=1479` and Claude Code's own `/login` URL was unusable. `&` is now part of a URL while `&&` remains a boundary.
|
||||
- Links wider than the terminal were cut at the row boundary. The link provider now stitches continuation rows into one logical line and maps offsets back across rows. Handles both soft wraps (emulator, `isWrapped`) and hard wraps (a program wrapping its own output and emitting a newline, as Ink does), the latter being why the `/login` URL grew longer as the window was widened.
|
||||
- Image and PDF paths were not matched at all, so pasted-screenshot paths rendered as plain text. They now link and open the file preview, which renders images inline.
|
||||
|
||||
**Also fixes** a pre-existing bug where `.toolbar`'s `backdrop-filter` created a stacking context that trapped the Run menu's z-index, letting the welcome overlay cover it: with no session open, every item in that menu (Claude Code included) was unclickable.
|
||||
|
||||
## 1.8.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -74,7 +74,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 1.8.1 (must match `package.json`)
|
||||
**Version**: 1.8.2 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -152,18 +152,19 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| **Tasks** | `src/task.ts`, `task-queue.ts`, `task-tracker.ts` | |
|
||||
| **State** | `src/state-store.ts`, `run-summary.ts`, `session-lifecycle-log.ts` | |
|
||||
| **Infra** | `src/hooks-config.ts`, `push-store`, `tunnel-manager`, `image-watcher`, `file-stream-manager`, `remote-hosts` + `remote-reconnect` (pure), `docker-hosts` + `docker-export` | Remote/docker case overlays; see Key Patterns |
|
||||
| **Web tabs** | `src/webview-store.ts`, `webview-capabilities.ts`, `src/web/webview-proxy.ts` (pure), `src/web/routes/webview-routes.ts` | Dashboard URLs as tabs; NOT a SessionMode |
|
||||
| **Search** | `src/search-service.ts` | Pure in-memory core for `GET /api/search` |
|
||||
| **Attachments** | `src/attachment-registry.ts`, `attachment-magic`, `generated-artifact-attachments`, `session-attachment-history`, `document-preview-cache`, `document-thumbnailer`, `document-conversion-limiter`, `config/attachment-guard` | See Key Patterns |
|
||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`) | `templates/` holds the CLAUDE.md scaffold generated into new cases |
|
||||
| **Web** | `src/web/server.ts` ★, `sse-events.ts`, `routes/*.ts` (20 modules + barrel; `session-routes.ts` ★), `route-helpers.ts`, `ports/*.ts`, `middleware/auth.ts`, `schemas.ts`, `self-update.ts`, `plan-usage-latest.ts`, `ws-connection-registry.ts`, `heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` | |
|
||||
| **Frontend** | `src/web/public/app.js` (~5K lines, core) + 23 modules + `sw.js` | See Frontend section for the load order, which is authoritative |
|
||||
| **Types** | `src/types/index.ts` (barrel) → 19 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
|
||||
| **Types** | `src/types/index.ts` (barrel) → 20 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
|
||||
|
||||
★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
|
||||
|
||||
**Local packages**: `packages/xterm-zerolag-input/` (local echo overlay, single-source, see Gotchas). `packages/gesture-control/` (`codeman-gesture-control`, hand-tracking overlay source, built via `npm run build:gesture`).
|
||||
|
||||
**Config**: `src/config/` — 16 files, no barrel (`index.ts`) exists; import from the specific file.
|
||||
**Config**: `src/config/` — 17 files, no barrel (`index.ts`) exists; import from the specific file.
|
||||
|
||||
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap` (⚠ NOT in the barrel — import from `./utils/lru-map.js` directly), `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver`/`codex-cli-resolver`/`gemini-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority).
|
||||
|
||||
@@ -214,6 +215,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search)
|
||||
|
||||
**Web tabs** (dashboard URLs as tabs): a saved URL renders as a tab beside agent sessions. **NOT a sixth `SessionMode`** (no PTY, no tmux, no respawn), same reasoning that keeps Docker/remote-SSH as case overlays. Dashboards are **proxied through Codeman's own origin** by default, because a direct iframe fails three ways at once: prod is HTTPS so `http://` targets are blocked as mixed content, many dashboards send `X-Frame-Options: DENY`, and our own `default-src 'self'` CSP blocks cross-origin frames. Proxying leaves the prod CSP unchanged (`/webview/...` is `'self'`). ⚠️ The proxy is **NOT an API surface**: it authenticates on an in-memory capability in the path and is correspondingly exempt from the cookie + Origin checks; that exemption is fenced to safe methods and non-`/api` paths and is pinned by `test/webview-auth-exemption.test.ts`. ⚠️ Iframes omit `allow-same-origin` unless a dashboard is explicitly marked `trusted`, and `Authorization`/`codeman_session` are stripped upstream in **both** modes so `CODEMAN_PASSWORD` cannot leak. ⚠️ A sandboxed frame is **opaque-origin**, which breaks two things `curl` can never reproduce: its runtime-built root-absolute URLs escape `<base>` (fixed by an injected `runtimeUrlShim()`), and its same-host `fetch`/XHR are CORS-checked with `Origin: null` (fixed by `buildProxyCorsHeaders()` plus exempting the proxy from the global `OPTIONS`-204 short-circuit in `registerSecurityHeaders`). Both present as the dashboard's own "Failed to fetch" while the page renders fine. → [architecture-invariants#web-tabs](docs/architecture-invariants.md#web-tabs), `docs/web-tabs.md`
|
||||
|
||||
**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default): named users with scrypt-hashed passwords in `~/.codeman/users.json`. Gated everywhere by `isMultiUserMode()`; when OFF, behavior is byte-identical to single-user because every scoping helper short-circuits. ⚠️ **Not a security boundary at the agent layer**: every session still runs as the SAME OS account. This separates WORKSPACES; it does not sandbox users (Docker cases are the isolation story). Ownership threads through `Session.owner` and is enforced in `findSessionOrFail`, list endpoints, SSE routing (fail-closed), WS, search, and file-preview. → [architecture-invariants#multi-user-mode](docs/architecture-invariants.md#multi-user-mode), `docs/multi-user-plan.md`
|
||||
|
||||
**Away digest**: `GET /api/away-digest` aggregates what happened while you were away from the lifecycle log, run-summary events, live sessions, token stats, and recent subagents. Pure aggregator in `web/away-digest.ts`. ⚠️ Returns `{success:true,digest}`, a legacy raw-ish shape consistent with the other raw GET handlers in `system-routes.ts`; frontend and tests read `.digest`. → [architecture-invariants#away-digest](docs/architecture-invariants.md#away-digest)
|
||||
@@ -224,7 +227,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
### Frontend
|
||||
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `ultracode-panel.js`(11.5) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `ultracode-panel.js`(11.5) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
|
||||
|
||||
**Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry)
|
||||
|
||||
@@ -274,11 +277,11 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
### SSE Event Registry
|
||||
|
||||
148 event constants in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). **Both must be kept in sync** — they are currently exactly in sync, and the backend file's `@fileoverview` carries the per-category breakdown.
|
||||
149 event constants in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). **Both must be kept in sync** — they are currently exactly in sync, and the backend file's `@fileoverview` carries the per-category breakdown.
|
||||
|
||||
### API Routes
|
||||
|
||||
~190 handlers across 20 route files in `src/web/routes/`: system (45), sessions (32), cases (27), files (14), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
~197 handlers across 21 route files in `src/web/routes/`: system (45), sessions (32), cases (27), files (14), orchestrator (10), ralph (9), cron (9), admin (8), plan (8), respawn (7), webviews (6 + the `/webview/:cap/*` proxy), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), me (2), teams (2), search (1), hooks (1), clipboard (1), status-telemetry (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
|
||||
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
|
||||
|
||||
@@ -296,7 +299,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
## State Files
|
||||
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `linked-cases.json`, `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
||||
All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`).
|
||||
|
||||
**Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored.
|
||||
|
||||
|
||||
@@ -90,6 +90,31 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
||||
|
||||
**Self-update** (App Settings → Updates): in-app updater for **git-clone installs** supervised by systemd/launchd. Supervisors: `systemd` (user unit), `launchd` (GUI LaunchAgent, gui-domain kickstart), `launchd-daemon` (KeepAlive system LaunchDaemon on headless Macs — restarts rootlessly by killing the server PID and letting launchd respawn it; detected only when the daemon is bootstrapped AND KeepAlive), else `none` → "restart manually" message; on next boot a manual-restart status auto-completes when the running version matches the target. The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` (`git checkout <release tag> && npm install && npm run build && restart`) that outlives the restart; it writes progress to `dataPath('update-status.json')`, which the browser polls across the connection drop. Channel = latest `codeman@X.Y.Z` release tag; dirty trees are auto-stashed. `src/web/self-update.ts` splits PURE helpers (semver/tag parsing, reconcile decision — unit-tested) from IO wrappers (`getInstallInfo`/`checkForUpdate`/`startUpdate`/`reconcileUpdateOnBoot`). Routes: `GET /api/system/update/check`, `POST /api/system/update`, `GET /api/system/update/status`. Types: `src/types/update.ts`. npm installs report as non-updatable.
|
||||
|
||||
### Web tabs
|
||||
|
||||
**Web tabs** (saved dashboard URLs rendered as tabs beside agent sessions; user guide `docs/web-tabs.md`). A webview is **NOT a sixth `SessionMode`**: no PTY, no tmux, no respawn, no idle detection. It is a separate resource (`~/.codeman/webviews.json` via `src/webview-store.ts`, types in `src/types/webview.ts`, limits in `src/config/webview-limits.ts`) that shares only the tab strip and the main content area, exactly as Docker and remote-SSH are case overlays rather than modes.
|
||||
|
||||
**Why there is a reverse proxy at all.** A direct `<iframe src="http://box:4000">` fails three independent ways in the shipped deployment: (1) prod serves `--https` behind `tailscale serve`, and browsers hard-block `http://` iframes on an HTTPS page with no override (none at all on iOS Safari); (2) Grafana/Portainer/Home-Assistant-class dashboards send `X-Frame-Options: DENY` or `frame-ancestors 'none'`; (3) our own `default-src 'self'` CSP makes `frame-src` fall back to `'self'`. Serving the dashboard through Codeman's origin dissolves all three, and as a bonus leaves the production CSP **byte-for-byte unchanged**, because `/webview/...` is already covered by `'self'`. A `direct` mode still exists for HTTPS targets that permit framing; `POST /api/webviews/probe` runs a server-side reachability + framing check and recommends which to use.
|
||||
|
||||
**Origin-scoped, not path-scoped.** `/webview/<cap>/x/y` always maps to `<upstream origin>/x/y`, never `<upstream origin><saved path>/x/y`. Dashboards reference assets root-absolutely (`/public/build/app.js`), so origin-scoping is the only mapping under which those resolve; the saved URL's own path+query is used solely as what `/webview/<cap>/` itself serves.
|
||||
|
||||
**The capability, and why the auth exemption is safe.** A sandboxed iframe (no `allow-same-origin`) is OPAQUE-ORIGIN, so every request it makes is cross-site: the `SameSite=lax` `codeman_session` cookie is never attached, and writes and WS upgrades arrive with `Origin: null`, which `isAllowedRequestOrigin` rejects by design. Cookie auth therefore cannot work. `src/webview-capabilities.ts` mints a 192-bit `randomBytes` token (memory-only, so a restart invalidates every outstanding one; rolling TTL; bound to the minting user; revoked on edit/delete) which `middleware/auth.ts` recognizes via `hasValidWebviewCapability()` to skip the cookie and Origin checks. ⚠️ The **Host allowlist is never bypassed**, so DNS-rebinding protection is intact. ⚠️ There is a second, `Referer`-keyed form of the exemption for root-absolute assets that `<base href>` cannot rewrite (`fetch('/api/data')`, `import('/chunk.js')`); it is the only exemption decided by a request-supplied header, so it is fenced to **safe methods on non-`/api`, non-`/ws`, non-`/q` paths**. Without that fence a page could present a webview Referer and skip auth on `/api`. `test/webview-auth-exemption.test.ts` pins every edge.
|
||||
|
||||
**Sandbox default.** The iframe carries `allow-scripts allow-forms allow-popups allow-downloads allow-modals` and gains `allow-same-origin` ONLY when the dashboard is explicitly `trusted`. A proxied page is served from Codeman's own origin, so granting it would let the dashboard read the Codeman document and drive the agent-spawning API. ⚠️ In **both** modes, `Authorization` and the `codeman_session` cookie are stripped before the upstream request (`buildUpstreamRequestHeaders`), because a trusted (same-origin) frame makes the browser attach Codeman's own Basic-auth header to every proxied request; forwarding it would hand `CODEMAN_PASSWORD` to the dashboard.
|
||||
|
||||
**Two things a sandboxed frame breaks that are invisible to `curl`.** Both were found only by driving a real dashboard in a real browser, and both present identically as the dashboard's own "Failed to fetch" while the page itself renders fine:
|
||||
|
||||
1. **Root-absolute URLs built at runtime.** `<base href>` only governs URLs the HTML parser resolves; `fetch('/api/data')` bypasses it and lands on Codeman's root. That is how most dashboards talk to their own backend. The `Referer`-keyed 404 fallback deliberately refuses `/api`, `/ws`, `/q` (widening it there would let a request-supplied header skip auth on Codeman's own API), so the fix is `runtimeUrlShim()`: a small script injected right after `<base>` that patches `fetch`, `XMLHttpRequest.open`, `WebSocket` and `EventSource` to rebase root-absolute and same-origin-absolute URLs into the prefix. It removes the whole class inside the iframe instead of trading security for it. ⚠️ It must be injected even when the page ships its OWN `<base>` (an early return there silently breaks exactly the pages that need it most).
|
||||
2. **CORS on same-host requests.** An opaque-origin document treats EVERY request as cross-origin, including to the very host it was served from, so its `fetch`/XHR are CORS-checked and its preflights carry `Origin: null`. Static subresources (script/css/img) are NOT CORS-checked, which is why the page renders while its API calls die with an opaque `net::ERR_FAILED`. `buildProxyCorsHeaders()` echoes the origin (omitting `allow-credentials` for `null`, which browsers reject in combination), upstream `access-control-*` headers are dropped (they describe the dashboard's origin, not the frame's), and the proxy answers preflights itself rather than relaying them. ⚠️ `registerSecurityHeaders` answers EVERY `OPTIONS` with a bare 204 before routing, and its CORS block only emits headers for localhost origins, so that short-circuit **must** exempt a valid webview capability or every preflight fails. `curl` cannot reproduce any of this because curl does not enforce CORS.
|
||||
|
||||
**Rewrites, each load-bearing** (pure + unit-tested in `src/web/webview-proxy.ts`): drop `x-frame-options` and the CSP `frame-ancestors` directive (the point of the proxy); drop `content-encoding`/`content-length` because undici's `fetch` already decoded the body (forwarding them makes the browser gunzip plaintext); rewrite `Location` for same-origin redirects only, handing CROSS-origin redirects back unchanged so this never becomes an open relay; rebase `Set-Cookie` `Path` onto the prefix and drop `Domain`; inject `<base href>` and rebase root-absolute `src`/`href`/`action`. `resolveUpstreamUrl()` returns null on anything escaping the upstream origin.
|
||||
|
||||
**Fastify specifics.** The proxy lives in an **encapsulated plugin scope** with `removeAllContentTypeParsers()` + a `'*'` pass-through parser, so raw bodies relay byte-for-byte while the root instance keeps its JSON parsing (and keeps `text/plain` RAW, which was a real CSRF hole once). One `app.route({ method:'GET', handler, wsHandler })` serves both HTTP and the WebSocket upgrade; **HEAD must NOT be declared** on the sibling route because `exposeHeadRoutes` already derives it and the duplicate is a startup error. ⚠️ **Every exit path in the proxy handler RETURNS `reply.send(...)`**: the handler is `async`, and a bare `return` after `reply.send(stream)` resolves the handler promise to `undefined` before the stream is consumed, so Fastify answers with an EMPTY body. HTML survives that (synchronous string payload) while every streamed asset comes back zero-length, which is a genuinely confusing failure. The root-absolute fallback hangs off `setNotFoundHandler`, so real Codeman routes always win.
|
||||
|
||||
**Frontend** (`src/web/public/webview-tabs.js`, load order 12.5): web tabs render into `#sessionTabs` carrying `data-webview-id` instead of `data-id`, so every session-tab path (drag-and-drop, alerts, badges) skips them. ⚠️ `_renderSessionTabsImmediate()`'s `canIncremental` check must include the web-tab comparison: the session-only comparison is vacuously "unchanged" whenever session count is stable, most visibly at ZERO sessions (`0 === 0`), where opening a dashboard would never draw its tab. ⚠️ `isActive` for a session tab is `id === activeSessionId && !activeWebviewId`: `activeSessionId` stays set while a web tab is showing (the terminal keeps streaming underneath), so without that clause the debounced re-render lights two tabs at once. Frames stay MOUNTED while hidden (LRU-evicted past `maxLiveFrames`) so tab switching never reloads a dashboard.
|
||||
|
||||
**Pre-existing bug fixed alongside**: `.toolbar` has `backdrop-filter`, which makes it a stacking context and TRAPS `.run-mode-menu`'s `z-index: 1000` inside it. With the toolbar itself at `z-index: auto`, `.welcome-overlay` (z-index 10, inside `<main>`) painted over the popped-up Run menu, making **every** item in it unclickable whenever no session was open. `.toolbar` now carries `z-index: 20` (must stay below `.modal`'s 1000).
|
||||
|
||||
### Multi-user mode
|
||||
|
||||
**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default; shipped 1.5.0 via PR #161, design `docs/multi-user-plan.md`): named users with individually scrypt-hashed passwords in `~/.codeman/users.json` (via `src/user-store.ts`: atomic 0600 write, short-TTL cache, SERIALIZED read-modify-write so a fire-and-forget `touchLastLogin` can't clobber a concurrent route write, last-admin invariants). Gated everywhere by `isMultiUserMode()` (`src/config/multiuser.ts`); when OFF, behavior is byte-identical to single-user (all scoping helpers short-circuit). ⚠️ **Not a security boundary at the agent layer** — every session still runs as the SAME OS account; this separates WORKSPACES, it does not sandbox users (Docker cases are the isolation story). Auth: a PARALLEL async branch in `middleware/auth.ts` (single-user branch untouched) verifies `username:password` against the store, mints identity-carrying cookies (`AuthSessionRecord` gains `username`/`role`/`mustChangePassword`), decorates `req.authUser` (Fastify augmentation; single-user leaves it undefined and the ownership helpers default to a synthetic admin), enforces a per-username failure bucket + the `mustChangePassword` lockbox. Ownership threads through `Session.owner` (stamped from `req.authUser`/`job.owner` at every `new Session()`, round-tripped via `MuxSession.owner` on recovery); `findSessionOrFail(ctx,id,req)` does a NOT_FOUND owner check; list endpoints + `getLightState` + SSE (`deriveSseHint` routes session-scoped events by owner, fail-closed; machine-level + host-plan telemetry admin-only) + WS + search + file-preview all filter by owner. §6.3 permission policy: non-granted users are forced to `--permission-mode auto` (via `resolveClaudeModeForUser` at all spawn sites, incl. one-shots because `buildPromptArgs` now respects the session mode), and shell mode / cron `launchCommand` require the `canBypassPermissions` grant. Cases live in per-user `~/codeman-users/<name>/cases` (`resolveCasesDir`); a non-admin's `workingDir` is realpath-confined there; host CRUD is admin-only. Admin API `src/web/routes/admin-routes.ts` (`/api/admin/users*`, one-time passwords, audit log `admin-audit.jsonl`) + self-service `/api/me` + `/api/me/password` (`me-routes.ts`); frontend `public/admin-ui.js` (identity boot, change-password modal + interceptor, admin Users tab, and the header **Admin Panel** button `#adminPanelBtn`: ships `btn-admin-panel--hidden`, revealed for admins in multi-user mode, phone-hidden via mobile.css; opens the full Admin Panel modal with user CRUD, per-user permission toggles, and case-folder list/delete via `GET/DELETE /api/admin/users/:username/cases[/:caseName]`; live-refreshes on SSE `admin:usersChanged`, wired in app.js). CLI `codeman users add|passwd|list|rm`. Per-user session cap via `sessionCapacityState`/`sessionCapacityMessage`. Tests: `test/user-store.test.ts`, `test/multiuser-auth.test.ts`, `test/ownership-scoping.test.ts`, `test/admin-routes.test.ts`, `test/admin-ui.test.ts`.
|
||||
|
||||
@@ -500,6 +500,16 @@ Full feature guide: [`docker-cases.md`](docker-cases.md).
|
||||
|
||||
---
|
||||
|
||||
## 10b. Web tabs (dashboard proxy)
|
||||
|
||||
A saved dashboard URL renders as a tab, served through Codeman's own origin at `/webview/<capability>/`. User guide: [`web-tabs.md`](web-tabs.md). Three properties carry the security weight:
|
||||
|
||||
- **The proxy is exempt from cookie auth and the Origin/CSRF guard, and that is deliberate.** The iframe is sandboxed without `allow-same-origin`, so it is opaque‑origin: its requests are cross‑site, meaning the `SameSite=lax` session cookie is never attached and its writes and WS upgrades arrive with `Origin: null`. The credential is instead a 192‑bit capability in the path, minted only by an authenticated `POST /api/webviews/:id/open`, held in memory (a restart invalidates every one), rolling TTL, bound to the minting user, and granting nothing but "relay bytes to this one saved URL". ⚠️ **The Host allowlist is NOT bypassed**, so DNS‑rebinding protection is unaffected. A second `Referer`‑keyed form exists for root‑absolute assets and is the only exemption decided by a request‑supplied header, so it is fenced to safe methods on non‑`/api`, non‑`/ws`, non‑`/q` paths. Edges pinned by `test/webview-auth-exemption.test.ts`.
|
||||
- **Sandboxed by default; `allow-same-origin` is an explicit per‑dashboard opt‑in.** A proxied page is same‑origin with Codeman, so without the sandbox its JavaScript could read the Codeman document and call the agent‑spawning API. ⚠️ In BOTH modes the `Authorization` header and the `codeman_session` cookie are stripped before the upstream request, because a trusted (same‑origin) frame makes the browser attach Codeman's own Basic‑auth credentials to every proxied request; forwarding them would hand `CODEMAN_PASSWORD` to the dashboard.
|
||||
- **Not an open relay, and not a privilege boundary.** `resolveUpstreamUrl()` refuses anything leaving the saved origin, and cross‑origin redirects are handed back unchanged rather than followed. The proxy does reach whatever the SERVER can reach, which is not an escalation for someone who already commands `--dangerously-skip-permissions` agents, but in multi‑user mode it means a non‑admin's dashboard is fetched from the server's network position. Saved URLs are validated to plain http(s) with no embedded credentials, and there is deliberately **no magic‑link path**: terminal output can never create a webview (the mistake the attachment scanner had to be walled off from).
|
||||
|
||||
---
|
||||
|
||||
## 11. Quick reference
|
||||
|
||||
| Env / flag | Effect |
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# Web Tabs (dashboards as Codeman tabs)
|
||||
|
||||
Open any dashboard you run, Grafana, Uptime Kuma, Portainer, a status page on port
|
||||
4000, as a tab beside your Claude/Codex/Gemini sessions. Codeman becomes one mission
|
||||
control instead of Codeman plus a pile of browser tabs.
|
||||
|
||||
## Using it
|
||||
|
||||
1. Click the chevron next to **Run** to expand the dropdown.
|
||||
2. Under **Web / URL**, pick **Add dashboard...**
|
||||
3. Give it a name and a URL, optionally hit **Test**, then **Save**.
|
||||
|
||||
The dashboard opens as a tab immediately, and appears in the Run dropdown from then
|
||||
on. Web tabs sit in the same strip as session tabs, continue the same `Alt+1..9`
|
||||
numbering, and carry a globe icon so they never read as a running agent.
|
||||
|
||||
Closing a tab (the `x`) only closes it. The saved dashboard stays in the dropdown.
|
||||
Deleting for good is behind the gear on the tab, or the gear on its dropdown row.
|
||||
|
||||
Switching tabs does **not** reload a dashboard. Frames stay alive in the background,
|
||||
so a dashboard that took a while to authenticate is still there when you come back.
|
||||
Past six live frames the least-recently-viewed one is dropped to bound memory
|
||||
(`CODEMAN_MAX_LIVE_WEBVIEW_FRAMES`).
|
||||
|
||||
## Why dashboards are proxied
|
||||
|
||||
A plain `<iframe src="http://your-box:4000">` does not work in the setup Codeman
|
||||
actually ships in, for three separate reasons:
|
||||
|
||||
| Blocker | What happens |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| **Mixed content** | Production serves HTTPS (behind `tailscale serve`). Browsers hard-block `http://` iframes on an HTTPS page, with no override, and none at all on iOS Safari. |
|
||||
| **Framing refusal** | Grafana, Portainer, Home Assistant and many others send `X-Frame-Options: DENY` or `frame-ancestors 'none'`. |
|
||||
| **Codeman's CSP** | `default-src 'self'` means `frame-src` falls back to `'self'`, so a cross-origin iframe is blocked before it starts. |
|
||||
|
||||
Serving the dashboard **through Codeman's own origin** dissolves all three. So by
|
||||
default a web tab loads `/webview/<capability>/` on Codeman, and Codeman relays to
|
||||
the dashboard: stripping the framing refusal, rewriting redirects, cookies and
|
||||
root-absolute URLs, and relaying WebSockets so live panels actually update.
|
||||
|
||||
A useful consequence: the dashboard is fetched **from the Codeman server**, so a
|
||||
tailnet-only or `localhost`-only dashboard works from any device that can reach
|
||||
Codeman, including a phone that is not on the tailnet.
|
||||
|
||||
`direct` mode (a plain cross-origin iframe) still exists and is cheaper, but it only
|
||||
works for an HTTPS dashboard that permits framing. The **Test** button probes from
|
||||
the server and tells you which mode applies.
|
||||
|
||||
## The sandbox, and when to turn it off
|
||||
|
||||
Because a proxied dashboard is served from Codeman's own address, it is
|
||||
*same-origin with Codeman* as far as the browser is concerned. Left unchecked, its
|
||||
JavaScript could read the Codeman page and call the API that spawns agents.
|
||||
|
||||
So the iframe is sandboxed **without** `allow-same-origin` by default. The page runs
|
||||
in an opaque origin: it cannot touch Codeman, and it gets no cookies or
|
||||
`localStorage` of its own.
|
||||
|
||||
Unchecking **Open sandboxed** grants `allow-same-origin`. Do that only for a
|
||||
dashboard you fully trust, and only if you need it, which in practice means a
|
||||
dashboard with its own login that stores a session in a cookie or `localStorage`.
|
||||
|
||||
Even in trusted mode, Codeman never forwards its own credentials upstream: the
|
||||
`Authorization` header and the `codeman_session` cookie are stripped on the way out,
|
||||
so `CODEMAN_PASSWORD` cannot leak into a dashboard.
|
||||
|
||||
## How the proxy authenticates
|
||||
|
||||
A sandboxed iframe is opaque-origin, so every request it makes is cross-site: the
|
||||
`SameSite=lax` session cookie is not sent, and writes and WebSocket upgrades arrive
|
||||
with `Origin: null`. Cookie auth cannot work.
|
||||
|
||||
Instead, opening a dashboard mints a **capability**: 192 bits of entropy in the URL
|
||||
path, held in memory only, with a rolling 12-hour TTL, bound to the user who minted
|
||||
it, and granting exactly one thing, relaying bytes to that one saved URL. Editing or
|
||||
deleting a dashboard revokes it, and a server restart invalidates every outstanding
|
||||
capability (tabs re-mint transparently on next click).
|
||||
|
||||
## Limits and env vars
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
| ------------------------------------ | ------- | ------------------------------------------ |
|
||||
| `CODEMAN_MAX_WEBVIEWS` | 50 | Saved dashboards per owner |
|
||||
| `CODEMAN_MAX_LIVE_WEBVIEW_FRAMES` | 6 | Iframes kept mounted at once |
|
||||
| `CODEMAN_WEBVIEW_CAPABILITY_TTL_MS` | 12h | Rolling capability lifetime |
|
||||
| `CODEMAN_WEBVIEW_TIMEOUT_MS` | 30000 | Upstream request timeout |
|
||||
| `CODEMAN_WEBVIEW_PROBE_TIMEOUT_MS` | 8000 | Timeout for the Test button |
|
||||
| `CODEMAN_MAX_WEBVIEW_HTML_BYTES` | 8MB | Largest HTML document rewritten |
|
||||
| `CODEMAN_MAX_WEBVIEW_SOCKETS` | 8 | Concurrent proxied WebSockets per dashboard |
|
||||
|
||||
Saved dashboards live in `~/.codeman/webviews.json`. Which tabs you have open is
|
||||
per-device (`localStorage`), since that is workspace layout rather than config.
|
||||
|
||||
## How a dashboard's own API calls keep working
|
||||
|
||||
Worth knowing, because it is where this feature does its least obvious work. Three
|
||||
layers cooperate so a dashboard talking to its own backend just works:
|
||||
|
||||
1. `<base href>` handles relative URLs in the markup.
|
||||
2. Attribute rewriting handles root-absolute `src`/`href`/`action`.
|
||||
3. A small injected script rebases URLs built at **runtime** (`fetch('/api/data')`,
|
||||
`new WebSocket('/live')`), which the first two cannot see.
|
||||
|
||||
On top of that, the proxy answers those requests with CORS headers. That sounds
|
||||
wrong for same-host requests, but a sandboxed iframe has an *opaque* origin, so the
|
||||
browser treats every one of its `fetch`/XHR calls as cross-origin even though the
|
||||
URL is on Codeman itself. Without those headers, a dashboard renders perfectly and
|
||||
then every API call fails, which looks like the dashboard being broken.
|
||||
|
||||
## Known limits
|
||||
|
||||
- **Exotic loaders.** The three layers above cover normal `fetch`/XHR/WebSocket/
|
||||
EventSource and normal markup. Something that constructs requests by an unusual
|
||||
route can still slip through. Symptom: the page renders but a panel stays empty.
|
||||
- **Cross-origin redirects are not followed.** If a dashboard bounces to a different
|
||||
host (an external SSO provider, say), the proxy hands the redirect back unchanged
|
||||
rather than relaying it, because relaying would make this an open proxy. Use
|
||||
**Open in new tab** for those.
|
||||
- **Login-protected dashboards need trusted mode**, since a sandboxed frame has no
|
||||
cookie jar. A server-side per-dashboard cookie jar would lift this and is the
|
||||
natural next step if it becomes annoying.
|
||||
- **Not a security boundary.** The proxy reaches whatever the Codeman server can
|
||||
reach. That is not an escalation for someone who already commands
|
||||
`--dangerously-skip-permissions` agents, but in multi-user mode it does mean a
|
||||
non-admin user's dashboard is fetched from the server's network position.
|
||||
|
||||
## Where the code lives
|
||||
|
||||
| Concern | File |
|
||||
| ------------------------ | --------------------------------------- |
|
||||
| Pure rewrite helpers | `src/web/webview-proxy.ts` |
|
||||
| Routes + proxy + sockets | `src/web/routes/webview-routes.ts` |
|
||||
| Capability tokens | `src/webview-capabilities.ts` |
|
||||
| Persistence | `src/webview-store.ts` |
|
||||
| Limits | `src/config/webview-limits.ts` |
|
||||
| Frontend | `src/web/public/webview-tabs.js` |
|
||||
| Auth exemption | `src/web/middleware/auth.ts` |
|
||||
Generated
+3
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.8.1",
|
||||
"version": "1.8.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.8.1",
|
||||
"version": "1.8.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -34,6 +34,7 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^14.0.0",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.21.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"bin": {
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.8.1",
|
||||
"version": "1.8.2",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -83,6 +83,7 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^14.0.0",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.21.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Limits and timeouts for web tabs (dashboards embedded as Codeman tabs).
|
||||
*
|
||||
* Every value here bounds something an untrusted-ish upstream controls: how many
|
||||
* dashboards can be saved, how long the server will wait on one, how much of a
|
||||
* response it will buffer before rewriting HTML, and how many sockets a single
|
||||
* dashboard may hold open. Env-overridable in the same style as the other config
|
||||
* modules.
|
||||
*/
|
||||
|
||||
function envInt(name: string, fallback: number): number {
|
||||
const parsed = parseInt(process.env[name] || '', 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
/** Max saved webviews (per owner in multi-user mode). */
|
||||
export const MAX_WEBVIEWS = envInt('CODEMAN_MAX_WEBVIEWS', 50);
|
||||
|
||||
/**
|
||||
* Max iframes kept mounted at once. Switching tabs must not reload a dashboard,
|
||||
* so frames stay alive while hidden; past this many, the least-recently-viewed
|
||||
* frame is evicted. Consumed by the frontend via `GET /api/webviews`.
|
||||
*/
|
||||
export const MAX_LIVE_WEBVIEW_FRAMES = envInt('CODEMAN_MAX_LIVE_WEBVIEW_FRAMES', 6);
|
||||
|
||||
/** How long a minted proxy capability stays valid (rolling, refreshed on use). */
|
||||
export const WEBVIEW_CAPABILITY_TTL_MS = envInt('CODEMAN_WEBVIEW_CAPABILITY_TTL_MS', 12 * 60 * 60 * 1000);
|
||||
|
||||
/** Max concurrent capabilities held in memory before the oldest are dropped. */
|
||||
export const MAX_WEBVIEW_CAPABILITIES = 200;
|
||||
|
||||
/** Upstream request timeout for a proxied HTTP request. */
|
||||
export const WEBVIEW_UPSTREAM_TIMEOUT_MS = envInt('CODEMAN_WEBVIEW_TIMEOUT_MS', 30_000);
|
||||
|
||||
/** Shorter timeout for the editor's "Test" probe, which a human is waiting on. */
|
||||
export const WEBVIEW_PROBE_TIMEOUT_MS = envInt('CODEMAN_WEBVIEW_PROBE_TIMEOUT_MS', 8_000);
|
||||
|
||||
/**
|
||||
* Max bytes of an HTML response buffered for `<base>` injection and link
|
||||
* rewriting. Larger HTML documents stream through untouched: the rewrite is a
|
||||
* convenience, and buffering an unbounded upstream body is a memory hazard.
|
||||
*/
|
||||
export const MAX_WEBVIEW_HTML_REWRITE_BYTES = envInt('CODEMAN_MAX_WEBVIEW_HTML_BYTES', 8 * 1024 * 1024);
|
||||
|
||||
/** Max concurrent proxied WebSockets per webview (mirrors MAX_WS_PER_SESSION). */
|
||||
export const MAX_WEBVIEW_SOCKETS = envInt('CODEMAN_MAX_WEBVIEW_SOCKETS', 8);
|
||||
|
||||
/** URL path prefix the proxy is mounted at. Single source of truth. */
|
||||
export const WEBVIEW_PROXY_PREFIX = '/webview';
|
||||
@@ -70,3 +70,4 @@ export * from './update.js';
|
||||
export * from './workflow-run.js';
|
||||
export * from './search.js';
|
||||
export * from './user.js';
|
||||
export * from './webview.js';
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @fileoverview Web tab (dashboard) types.
|
||||
*
|
||||
* A "webview" is a saved URL that Codeman renders as a tab alongside agent
|
||||
* sessions: Grafana on :3000, a Uptime-Kuma on :4000, an internal status page.
|
||||
* It is deliberately NOT a sixth `SessionMode`, it has no PTY, no tmux, no
|
||||
* respawn and no idle detection. Same reasoning that keeps Docker and remote-SSH
|
||||
* as case overlays rather than modes.
|
||||
*
|
||||
* Key exports:
|
||||
* - Webview, the persisted record (`~/.codeman/webviews.json`).
|
||||
* - WebviewEmbedMode, 'proxy' (served through Codeman's origin) or 'direct'
|
||||
* (a plain cross-origin iframe, only viable for HTTPS targets that allow framing).
|
||||
* - WebviewProbe, the result of the server-side reachability/framing probe.
|
||||
* - WebviewOpenData, what `POST /api/webviews/:id/open` hands the browser.
|
||||
*
|
||||
* No I/O here. Persistence lives in `src/webview-store.ts`, capability minting in
|
||||
* `src/webview-capabilities.ts`, the proxy helpers in `src/web/webview-proxy.ts`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How the browser should embed a webview.
|
||||
*
|
||||
* - `proxy`: the iframe points at `/webview/<capability>/` on Codeman's own
|
||||
* origin and the server relays to the target. Required whenever the target is
|
||||
* plain HTTP (an HTTPS Codeman page cannot embed it: mixed content) or refuses
|
||||
* framing via `X-Frame-Options` / `frame-ancestors`.
|
||||
* - `direct`: the iframe points at the target URL itself. Cheaper, but only works
|
||||
* for HTTPS targets that permit framing, and needs the target origin added to
|
||||
* the page CSP's `frame-src`.
|
||||
*/
|
||||
export type WebviewEmbedMode = 'proxy' | 'direct';
|
||||
|
||||
/** A saved dashboard, persisted to `~/.codeman/webviews.json`. */
|
||||
export interface Webview {
|
||||
id: string;
|
||||
/** Display name shown on the tab. */
|
||||
name: string;
|
||||
/** Absolute target URL. `http:` / `https:` only, never with embedded credentials. */
|
||||
url: string;
|
||||
/** Optional single-glyph tab icon (emoji or letter). */
|
||||
icon?: string;
|
||||
/** Default embed strategy for this dashboard. */
|
||||
embedMode: WebviewEmbedMode;
|
||||
/**
|
||||
* When false (the default) the iframe is sandboxed WITHOUT `allow-same-origin`,
|
||||
* so a proxied page runs in an opaque origin and cannot read the Codeman page or
|
||||
* call its API. Setting this to true trades that isolation for the page's own
|
||||
* cookies/localStorage, only for dashboards the user fully trusts.
|
||||
*/
|
||||
trusted: boolean;
|
||||
/** Multi-user owner (username). Undefined in single-user mode. */
|
||||
owner?: string;
|
||||
createdAt: number;
|
||||
lastOpenedAt?: number;
|
||||
}
|
||||
|
||||
/** Result of the server-side probe used by the "Test" button in the editor. */
|
||||
export interface WebviewProbe {
|
||||
/** True when the server could complete an HTTP request to the target. */
|
||||
reachable: boolean;
|
||||
/** Upstream status code, when a response came back. */
|
||||
status?: number;
|
||||
/** Raw `X-Frame-Options` value, if the target sent one. */
|
||||
xFrameOptions?: string;
|
||||
/** The `frame-ancestors` directive extracted from the target's CSP, if any. */
|
||||
frameAncestors?: string;
|
||||
/** True when the target permits being framed cross-origin by this Codeman. */
|
||||
framable: boolean;
|
||||
/** Strategy the UI should default to for this URL. */
|
||||
recommendedMode: WebviewEmbedMode;
|
||||
/** Human-readable explanation of the recommendation (or the failure). */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Payload of `POST /api/webviews/:id/open`. */
|
||||
export interface WebviewOpenData {
|
||||
/** The webview being opened (echoed so the client can refresh its copy). */
|
||||
webview: Webview;
|
||||
/**
|
||||
* Same-origin path the iframe should load. Present for `proxy` mode only;
|
||||
* `direct` mode uses `webview.url` instead.
|
||||
*/
|
||||
embedUrl?: string;
|
||||
/** Epoch ms at which the capability behind `embedUrl` stops working. */
|
||||
expiresAt?: number;
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { findUser, setPassword, touchLastLogin, verifyPassword } from '../../user-store.js';
|
||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
import { capabilityFromProxyPath, capabilityFromReferer } from '../webview-proxy.js';
|
||||
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../../types.js';
|
||||
|
||||
// Request-scoped identity (multi-user). Single-user leaves it undefined and the
|
||||
@@ -120,6 +122,49 @@ function isPasswordChangeExempt(req: FastifyRequest): boolean {
|
||||
return !url.startsWith('/api/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this request carries a VALID web-tab proxy capability.
|
||||
*
|
||||
* Requests under `/webview/<cap>/` cannot authenticate the normal way. The iframe
|
||||
* rendering a dashboard is sandboxed without `allow-same-origin`, so it runs in an
|
||||
* opaque origin: every request it makes is cross-site, meaning the `SameSite=lax`
|
||||
* `codeman_session` cookie is never attached, and non-GET requests and WebSocket
|
||||
* upgrades arrive with `Origin: null`. Both the cookie check and the CSRF Origin
|
||||
* guard would therefore reject a perfectly legitimate dashboard asset load.
|
||||
*
|
||||
* The capability in the path is the credential instead: 192 bits of entropy, held
|
||||
* in memory only (a restart invalidates it), rolling TTL, bound to the user who
|
||||
* minted it through an already-authenticated `POST /api/webviews/:id/open`, and
|
||||
* granting nothing but "relay bytes to this one saved URL".
|
||||
*
|
||||
* The exemption is deliberately narrow: it requires the capability to RESOLVE, so
|
||||
* a bare `/webview/anything` reaches nothing, and a `/webviewfoo` path does not
|
||||
* match the prefix at all. The Host allowlist is NOT bypassed, so DNS-rebinding
|
||||
* protection still applies to these requests.
|
||||
*/
|
||||
function hasValidWebviewCapability(req: FastifyRequest): boolean {
|
||||
const url = (req.url ?? '').split('?')[0];
|
||||
|
||||
const fromPath = capabilityFromProxyPath(url);
|
||||
if (fromPath) return webviewCapabilities.resolve(fromPath) !== undefined;
|
||||
|
||||
// Referer form: a dashboard subresource requested with a ROOT-ABSOLUTE URL, which
|
||||
// lands on Codeman's root and is relayed by the 404 fallback. Without this the
|
||||
// asset would be rejected here, before the fallback ever runs.
|
||||
//
|
||||
// This is the only exemption decided by a header the request itself supplies, so
|
||||
// it is fenced in hard: safe methods only, and never for Codeman's own functional
|
||||
// surfaces. Without those fences a page could present a webview Referer and skip
|
||||
// auth on /api. It is not a privilege escalation even so, holding a live
|
||||
// capability already implies an authenticated `POST /api/webviews/:id/open`, but
|
||||
// the exemption should stay no wider than the problem it solves.
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
|
||||
if (url.startsWith('/api/') || url.startsWith('/ws/') || url.startsWith('/q/')) return false;
|
||||
|
||||
const fromReferer = capabilityFromReferer(typeof req.headers.referer === 'string' ? req.headers.referer : undefined);
|
||||
return !!fromReferer && webviewCapabilities.resolve(fromReferer) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register HTTP Basic Auth middleware with session cookies and rate limiting.
|
||||
* Only active when CODEMAN_PASSWORD is set.
|
||||
@@ -211,6 +256,12 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
return;
|
||||
}
|
||||
|
||||
// Web-tab proxy, authenticated by the capability in the path, not the cookie.
|
||||
if (hasValidWebviewCapability(req)) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
const clientIp = req.ip;
|
||||
|
||||
// Check session cookie first (avoids re-sending credentials on every request)
|
||||
@@ -341,6 +392,12 @@ function registerMultiUserAuthHook(
|
||||
// QR redemption path — handled by the route itself.
|
||||
if (req.url?.startsWith('/q/')) return;
|
||||
|
||||
// Web-tab proxy, authenticated by the capability in the path, not the cookie.
|
||||
// `req.authUser` stays undefined here on purpose: the proxy handler enforces
|
||||
// ownership against the identity BOUND TO THE CAPABILITY, which is stricter
|
||||
// than re-deriving it from a request that carries no credentials.
|
||||
if (hasValidWebviewCapability(req)) return;
|
||||
|
||||
const clientIp = req.ip;
|
||||
|
||||
// 1. Cookie session (carries identity + mustChangePassword snapshot).
|
||||
@@ -466,7 +523,17 @@ export function registerHostGuard(app: FastifyInstance, getPolicy: () => HostPol
|
||||
reply.code(403).send('Forbidden: host not allowed');
|
||||
return;
|
||||
}
|
||||
if (!SAFE_HTTP_METHODS.has(req.method) && !isAllowedRequestOrigin(req.headers.origin, policy)) {
|
||||
// The Host allowlist above is NEVER bypassed. The Origin (CSRF) check is,
|
||||
// but only for a request carrying a valid web-tab capability: a sandboxed
|
||||
// dashboard is opaque-origin, so its form posts and uploads arrive with
|
||||
// `Origin: null`, which this guard rejects by design. The capability is the
|
||||
// credential in that case, and it is unguessable, see
|
||||
// hasValidWebviewCapability.
|
||||
if (
|
||||
!SAFE_HTTP_METHODS.has(req.method) &&
|
||||
!isAllowedRequestOrigin(req.headers.origin, policy) &&
|
||||
!hasValidWebviewCapability(req)
|
||||
) {
|
||||
reply.code(403).send('Forbidden: cross-site request blocked');
|
||||
return;
|
||||
}
|
||||
@@ -521,8 +588,17 @@ export function registerSecurityHeaders(app: FastifyInstance, https: boolean): v
|
||||
}
|
||||
}
|
||||
|
||||
// Handle CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
// Handle CORS preflight.
|
||||
//
|
||||
// EXCEPT for the web-tab proxy, which must answer its own preflight. A
|
||||
// sandboxed dashboard iframe is opaque-origin, so it sends `Origin: null`;
|
||||
// the CORS block above only emits headers for localhost origins, so a bare
|
||||
// 204 from here carries no `Access-Control-Allow-Origin` and the browser
|
||||
// rejects the preflight. Every dashboard fetch then fails with an opaque
|
||||
// net::ERR_FAILED while the page itself renders fine (script/css/img loads
|
||||
// are not CORS-checked). Falling through lets the proxy route reply with the
|
||||
// right headers.
|
||||
if (req.method === 'OPTIONS' && !hasValidWebviewCapability(req)) {
|
||||
reply.code(204).send();
|
||||
done();
|
||||
return;
|
||||
|
||||
+44
-4
@@ -294,6 +294,9 @@ const _SSE_HANDLER_MAP = [
|
||||
|
||||
// Session order (global tab order sync, COD-131)
|
||||
[SSE_EVENTS.SESSION_ORDER_CHANGED, '_onSessionOrderChanged'],
|
||||
|
||||
// Web tabs (dashboard URLs)
|
||||
[SSE_EVENTS.WEBVIEW_CHANGED, '_onWebviewChanged'],
|
||||
];
|
||||
|
||||
|
||||
@@ -821,6 +824,7 @@ class CodemanApp {
|
||||
const settingsPromise = fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.data ?? null).catch(() => null);
|
||||
this.loadQuickStartCases(null, settingsPromise);
|
||||
this._initRunMode();
|
||||
this.initWebviews?.();
|
||||
this.setupEventListeners();
|
||||
// Mobile: ensure button taps register even when keyboard is visible.
|
||||
// On mobile, tapping a button while the soft keyboard is up causes the
|
||||
@@ -1007,9 +1011,18 @@ class CodemanApp {
|
||||
const digitMatch = code.match(/^Digit([1-9])$/);
|
||||
if (digitMatch) {
|
||||
const idx = parseInt(digitMatch[1], 10) - 1;
|
||||
// Sessions occupy 1..N and web tabs continue from N+1, matching the
|
||||
// numbers actually painted on the tabs.
|
||||
if (idx < this.sessionOrder.length) {
|
||||
e.preventDefault();
|
||||
this.selectSession(this.sessionOrder[idx]);
|
||||
} else {
|
||||
const webIdx = idx - this.sessionOrder.length;
|
||||
const webId = (this.webviewOrder || [])[webIdx];
|
||||
if (webId) {
|
||||
e.preventDefault();
|
||||
this.openWebview(webId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -3272,9 +3285,21 @@ class CodemanApp {
|
||||
const existingIds = new Set([...existingTabs].map(t => t.dataset.id));
|
||||
const currentIds = new Set(this.sessions.keys());
|
||||
|
||||
// Check if we can do incremental update (same session IDs)
|
||||
// Web tabs live in the same strip but are not in this.sessions, so they need
|
||||
// their own change check. Without it, the session-only comparison below is
|
||||
// vacuously "unchanged" whenever session count is stable — most visibly with
|
||||
// ZERO sessions (0 === 0), where opening a dashboard would never draw its tab.
|
||||
const existingWebIds = [...container.querySelectorAll('.session-tab[data-webview-id]')].map(
|
||||
t => t.dataset.webviewId
|
||||
);
|
||||
const wantedWebIds = (this.webviewOrder || []).filter(id => this.webviews?.has(id));
|
||||
const webTabsUnchanged =
|
||||
existingWebIds.length === wantedWebIds.length && existingWebIds.every((id, i) => id === wantedWebIds[i]);
|
||||
|
||||
// Check if we can do incremental update (same session IDs and same web tabs)
|
||||
const canIncremental = existingIds.size === currentIds.size &&
|
||||
[...existingIds].every(id => currentIds.has(id));
|
||||
[...existingIds].every(id => currentIds.has(id)) &&
|
||||
webTabsUnchanged;
|
||||
|
||||
if (canIncremental) {
|
||||
// Incremental update - only modify changed properties
|
||||
@@ -3282,7 +3307,12 @@ class CodemanApp {
|
||||
const tab = container.querySelector(`.session-tab[data-id="${id}"]`);
|
||||
if (!tab) continue;
|
||||
|
||||
const isActive = id === this.activeSessionId;
|
||||
// A web tab owns the active state while one is open. activeSessionId stays
|
||||
// set (the terminal keeps streaming underneath, and switching back is
|
||||
// instant): only the highlight moves. Without this the debounced render
|
||||
// re-marks the session tab active moments after a web tab was selected,
|
||||
// leaving two tabs lit at once.
|
||||
const isActive = id === this.activeSessionId && !this.activeWebviewId;
|
||||
const status = session.status || 'idle';
|
||||
const name = this.getSessionName(session);
|
||||
const taskStats = session.taskStats || { running: 0, total: 0 };
|
||||
@@ -3469,7 +3499,9 @@ class CodemanApp {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) continue; // Skip if session was removed
|
||||
|
||||
const isActive = id === this.activeSessionId;
|
||||
// See the note in the incremental path: a web tab owns the active highlight
|
||||
// while one is open, even though activeSessionId stays set.
|
||||
const isActive = id === this.activeSessionId && !this.activeWebviewId;
|
||||
const status = session.status || 'idle';
|
||||
const name = this.getSessionName(session);
|
||||
const mode = session.mode || 'claude';
|
||||
@@ -3516,6 +3548,11 @@ class CodemanApp {
|
||||
_tabIdx++;
|
||||
}
|
||||
|
||||
// Web tabs (dashboard URLs) render after the session tabs, continuing the
|
||||
// Alt+N numbering. They carry data-webview-id instead of data-id, so every
|
||||
// session-tab code path above (drag-and-drop, alerts, badges) skips them.
|
||||
parts.push(this.renderWebviewTabs ? this.renderWebviewTabs(_tabIdx) : '');
|
||||
|
||||
container.innerHTML = parts.join('');
|
||||
|
||||
// Set up drag-and-drop handlers for tab reordering
|
||||
@@ -4050,6 +4087,9 @@ class CodemanApp {
|
||||
return; // newer tab switch won
|
||||
}
|
||||
|
||||
// A session tab takes the stage back from any active web tab.
|
||||
this._hideWebviewLayer?.();
|
||||
|
||||
this._cleanupPreviousSession(sessionId);
|
||||
this.activeSessionId = sessionId;
|
||||
try { localStorage.setItem('codeman-active-session', sessionId); } catch {}
|
||||
|
||||
@@ -494,6 +494,9 @@ const SSE_EVENTS = {
|
||||
|
||||
// Session order (global tab order sync)
|
||||
SESSION_ORDER_CHANGED: 'session:orderChanged',
|
||||
|
||||
// Web tabs (dashboard URLs)
|
||||
WEBVIEW_CHANGED: 'webview:changed',
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -300,6 +300,11 @@
|
||||
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Web tab layer: one iframe per open dashboard, shown in place of the
|
||||
terminal while a web tab is active. Frames stay mounted while hidden so
|
||||
switching tabs does not reload (and re-authenticate) a dashboard. -->
|
||||
<div class="webview-layer" id="webviewLayer"></div>
|
||||
|
||||
<!-- Welcome Overlay (shown when no session active) -->
|
||||
<div class="welcome-overlay" id="welcomeOverlay">
|
||||
<div class="welcome-content">
|
||||
@@ -464,6 +469,14 @@
|
||||
<span class="run-mode-dot shell"></span>Terminal / Shell
|
||||
</button>
|
||||
<div class="run-mode-sep"></div>
|
||||
<!-- Web tabs: dashboards open as tabs beside agent sessions. These do NOT
|
||||
set runMode: the Run button always means "start an agent". -->
|
||||
<div class="run-mode-header">Web / URL</div>
|
||||
<div class="run-mode-webviews" id="runModeWebviews"></div>
|
||||
<button class="run-mode-option run-mode-option--add" onclick="app.showWebviewModal()">
|
||||
<span class="run-mode-dot web"></span>Add URL…
|
||||
</button>
|
||||
<div class="run-mode-sep"></div>
|
||||
<div class="run-mode-header">Recent Sessions</div>
|
||||
<div class="run-mode-history" id="runModeHistory"></div>
|
||||
</div>
|
||||
@@ -639,6 +652,56 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Web Tab (dashboard URL) editor -->
|
||||
<div class="modal" id="webviewModal">
|
||||
<div class="modal-backdrop" onclick="app.closeWebviewModal()"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="webviewModalTitle">Add URL</h3>
|
||||
<button class="modal-close" onclick="app.closeWebviewModal()" aria-label="Close URL editor">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-row">
|
||||
<label for="webviewName">Name</label>
|
||||
<input type="text" id="webviewName" placeholder="Grafana" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="webviewUrl">URL</label>
|
||||
<input type="text" id="webviewUrl" placeholder="http://100.70.56.18:4000/" autocomplete="off"
|
||||
autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">
|
||||
Reached from the Codeman server, so a tailnet or localhost address works even when
|
||||
this browser cannot see it. Plain HTTP is fine: the dashboard is proxied through
|
||||
Codeman, which is also what gets past dashboards that refuse to be embedded.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="webviewIcon">Icon</label>
|
||||
<!-- Click to pick; the field stays editable so any emoji still works. -->
|
||||
<div class="webview-icon-picker" id="webviewIconPicker" role="group" aria-label="Choose an icon"></div>
|
||||
<input type="text" id="webviewIcon" placeholder="Or paste any emoji" maxlength="8" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="webviewSandboxed" checked> Open sandboxed</label>
|
||||
<span class="form-hint">
|
||||
Recommended. A proxied dashboard is served from Codeman's own address, so unchecking
|
||||
this lets its JavaScript read this page and call the API that starts agents. Uncheck
|
||||
only for a dashboard you fully trust, or one whose own login needs cookies.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<button class="btn-secondary" onclick="app.testWebviewUrl()">Test</button>
|
||||
<span class="form-hint webview-probe-result" id="webviewProbeResult"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions webview-modal-actions">
|
||||
<button class="btn-danger" id="webviewDeleteBtn" onclick="app.deleteWebview()">Delete</button>
|
||||
<button class="btn-secondary" onclick="app.closeWebviewModal()">Cancel</button>
|
||||
<button class="btn-primary" onclick="app.saveWebview()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cron Jobs Modal -->
|
||||
<div class="modal" id="cronModal">
|
||||
<div class="modal-backdrop" onclick="app.closeCron()"></div>
|
||||
@@ -2551,6 +2614,7 @@
|
||||
<script defer src="ultracode-panel.js"></script>
|
||||
<script defer src="admin-ui.js"></script>
|
||||
<script defer src="session-ui.js"></script>
|
||||
<script defer src="webview-tabs.js"></script>
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
<script defer src="subagent-windows.js"></script>
|
||||
|
||||
@@ -3129,6 +3129,12 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
/* contain: style only — layout/paint containment clips the case-settings popover
|
||||
that extends above the toolbar (popover uses position:absolute + bottom:100%) */
|
||||
contain: style;
|
||||
/* backdrop-filter above makes this a stacking context, which TRAPS the
|
||||
z-index:1000 on .run-mode-menu inside it. Without an explicit z-index here the
|
||||
toolbar resolves to auto (0) and .welcome-overlay (z-index:10, inside <main>)
|
||||
paints over the popped-up Run menu: with no session open, every item in that
|
||||
menu is unclickable. Must stay below .modal (1000). */
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
/* backdrop-filter creates a stacking context, trapping the popover's
|
||||
@@ -11854,3 +11860,118 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
|
||||
display: flex; gap: 0.5rem; justify-content: flex-end;
|
||||
margin-top: 1rem; padding-top: 0.85rem; border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Web tabs (dashboard URLs embedded as tabs)
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* The iframe layer sits alongside .terminal-wrap inside <main> and only one of
|
||||
the two is visible at a time. Frames stay in the DOM while hidden so switching
|
||||
tabs does not reload a dashboard. */
|
||||
.webview-layer {
|
||||
display: none;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
background: var(--term-bg, #161b23);
|
||||
}
|
||||
.main.webview-active .webview-layer { display: flex; }
|
||||
.main.webview-active .terminal-wrap { display: none; }
|
||||
|
||||
.webview-frame {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.webview-frame.active { display: block; }
|
||||
|
||||
.webview-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* Shown only when the frame never signalled load: a refused embed or an
|
||||
unreachable host would otherwise be an unexplained blank rectangle. */
|
||||
.webview-failure { display: none; }
|
||||
.webview-frame--failed .webview-failure {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg, #0f1319);
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.webview-failure-inner { max-width: 380px; }
|
||||
.webview-failure-inner h3 { margin: 0 0 0.5rem; font-size: 1rem; color: var(--text); }
|
||||
.webview-failure-inner p { margin: 0 0 1rem; font-size: 0.85rem; color: var(--text-dim); line-height: 1.5; }
|
||||
.webview-failure-actions { display: flex; gap: 0.5rem; justify-content: center; flex-wrap: wrap; }
|
||||
|
||||
/* Tab styling: same shape as a session tab, distinguished by the globe icon and
|
||||
a cool accent so a dashboard never reads as a running agent. */
|
||||
.session-tab--web .tab-web-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.session-tab--web.active .tab-web-icon { color: var(--accent, #3ec8ee); }
|
||||
.session-tab--web.active { border-bottom-color: var(--accent, #3ec8ee); }
|
||||
|
||||
.run-mode-dot.web { background: #38bdf8; }
|
||||
.run-mode-webviews { max-height: 180px; overflow-y: auto; }
|
||||
.run-mode-empty {
|
||||
padding: 4px 10px 6px;
|
||||
font-size: 0.75em;
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Icon picker: a compact grid above the free-text field, so the common case is a
|
||||
click and the escape hatch (any emoji at all) stays available. */
|
||||
.webview-icon-picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.webview-icon-choice {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-soft, rgba(255, 255, 255, 0.03));
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.webview-icon-choice:hover { border-color: var(--accent, #3ec8ee); }
|
||||
.webview-icon-choice.selected {
|
||||
border-color: var(--accent, #3ec8ee);
|
||||
box-shadow: 0 0 0 1px var(--accent, #3ec8ee) inset;
|
||||
}
|
||||
|
||||
/* Saved-URL rows in the Run dropdown show their chosen icon in the dot's slot. */
|
||||
.run-mode-menu-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
margin-right: 6px;
|
||||
font-size: 0.95em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.webview-probe-result.ok { color: var(--success, #10b981); }
|
||||
.webview-probe-result.bad { color: var(--danger, #ef4444); }
|
||||
/* Delete sits apart from Cancel/Save so it is not fat-fingered on the way to Save. */
|
||||
.webview-modal-actions { justify-content: space-between; }
|
||||
.webview-modal-actions .btn-danger { margin-right: auto; }
|
||||
|
||||
@@ -969,8 +969,66 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get line text - translateToString handles wrapped lines
|
||||
const lineText = line.translateToString(true);
|
||||
// Stitch the LOGICAL line back together.
|
||||
//
|
||||
// xterm invokes this provider per visible ROW, and translateToString returns
|
||||
// that row alone (the old comment here claimed otherwise). A URL or path
|
||||
// longer than the terminal is wide therefore matched only as far as the row
|
||||
// boundary, and the link opened a PREFIX of the real target. Walk out to both
|
||||
// ends of the continuation, match against the joined text, and map offsets
|
||||
// back to (x, y) so a link can span rows.
|
||||
//
|
||||
// Two different kinds of continuation, and handling only the first is not
|
||||
// enough:
|
||||
// 1. SOFT wrap: the emulator ran out of columns and flags the next row
|
||||
// `isWrapped`.
|
||||
// 2. HARD wrap: the program did its own wrapping and emitted a real
|
||||
// newline, so nothing is flagged. Ink does this, which is why Claude
|
||||
// Code's own `/login` URL was cut at the window edge, and why the
|
||||
// clickable part grew when the window was widened.
|
||||
// A row that fills the full width is treated as continuing into the next:
|
||||
// that is the signal a hard wrap leaves behind, and a line that genuinely
|
||||
// ended would stop short of the last column.
|
||||
const cols = self.terminal.cols;
|
||||
const rowAt = (r) => buffer.getLine(r - 1);
|
||||
const continuesPrevious = (r) => {
|
||||
if (r <= 1) return false;
|
||||
if (rowAt(r)?.isWrapped) return true;
|
||||
const prev = rowAt(r - 1);
|
||||
return !!prev && prev.translateToString(true).length >= cols;
|
||||
};
|
||||
|
||||
// Bounded so a screenful of full-width output (wide tables, box drawing)
|
||||
// cannot make every hover stitch and re-scan the entire viewport.
|
||||
const MAX_STITCHED_ROWS = 12;
|
||||
let startRow = bufferLineNumber;
|
||||
while (startRow > 1 && bufferLineNumber - startRow < MAX_STITCHED_ROWS && continuesPrevious(startRow)) {
|
||||
startRow--;
|
||||
}
|
||||
let endRow = bufferLineNumber;
|
||||
while (endRow < buffer.length && endRow - startRow < MAX_STITCHED_ROWS && continuesPrevious(endRow + 1)) {
|
||||
endRow++;
|
||||
}
|
||||
|
||||
const rowTexts = [];
|
||||
for (let r = startRow; r <= endRow; r++) {
|
||||
const row = rowAt(r);
|
||||
if (!row) break;
|
||||
// Only the final row may be trimmed. Continuation rows fill the width by
|
||||
// definition, and trimming one would shift every later offset.
|
||||
rowTexts.push(row.translateToString(r === endRow));
|
||||
}
|
||||
const lineText = rowTexts.join('');
|
||||
|
||||
/** Map an offset in the stitched text back to a 1-based terminal cell. */
|
||||
const coordAt = (index) => {
|
||||
let rest = index;
|
||||
for (let i = 0; i < rowTexts.length - 1; i++) {
|
||||
if (rest < rowTexts[i].length) return { x: rest + 1, y: startRow + i };
|
||||
rest -= rowTexts[i].length;
|
||||
}
|
||||
return { x: rest + 1, y: startRow + rowTexts.length - 1 };
|
||||
};
|
||||
|
||||
if (!lineText || !lineText.includes('/')) {
|
||||
callback(undefined);
|
||||
@@ -980,22 +1038,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
const links = [];
|
||||
|
||||
// Pattern 0: URLs (https://, http://) — matched first so they take priority
|
||||
const urlPattern = /https?:\/\/[^\s"'<>|;&)\]\x00-\x1f]+/g;
|
||||
//
|
||||
// A single `&` is PART of the URL: it separates query parameters, so excluding
|
||||
// it truncated every real query string (`?post=1479&action=edit` linked only
|
||||
// through `1479`, landing on the wrong page). `&&` is still a boundary, since
|
||||
// that is the shell operator and never appears inside a URL. A lone trailing
|
||||
// `&` is trimmed below with the other trailing punctuation.
|
||||
const urlPattern = /https?:\/\/(?:[^\s"'<>|;&)\]\x00-\x1f]|&(?!&))+/g;
|
||||
|
||||
const addUrlLink = (url, matchIndex) => {
|
||||
// Strip trailing punctuation that's likely not part of the URL
|
||||
const cleaned = url.replace(/[.,;:!?)]+$/, '');
|
||||
const cleaned = url.replace(/[.,;:!?)&]+$/, '');
|
||||
const startCol = lineText.indexOf(cleaned, matchIndex);
|
||||
if (startCol === -1) return;
|
||||
|
||||
if (links.some((l) => l.range.start.x === startCol + 1)) return;
|
||||
const start = coordAt(startCol);
|
||||
const end = coordAt(startCol + cleaned.length);
|
||||
if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return;
|
||||
|
||||
links.push({
|
||||
text: cleaned,
|
||||
range: {
|
||||
start: { x: startCol + 1, y: bufferLineNumber },
|
||||
end: { x: startCol + cleaned.length + 1, y: bufferLineNumber },
|
||||
},
|
||||
range: { start, end },
|
||||
decorations: { pointerCursor: true, underline: true },
|
||||
activate(_event, text) {
|
||||
window.open(text, '_blank', 'noopener,noreferrer');
|
||||
@@ -1017,31 +1080,43 @@ Object.assign(CodemanApp.prototype, {
|
||||
// the whole tab on hover. Non-empty token + bounded reps is O(n).
|
||||
const cmdPattern = /\b(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]+\s+){0,4}(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
|
||||
|
||||
// Pattern 2: Paths with common extensions
|
||||
// Pattern 2: Paths with common extensions.
|
||||
// Image/PDF extensions are included so pasted-attachment paths
|
||||
// (`.claude-images/paste-*.png`) are clickable; they open the file preview
|
||||
// rather than the log viewer (see addLink).
|
||||
const extPattern =
|
||||
/(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
|
||||
/(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js|png|jpe?g|gif|webp|bmp|svg|pdf))\b/g;
|
||||
|
||||
// Pattern 3: Bash() tool output
|
||||
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
|
||||
|
||||
/** Extensions that should open the image/document preview, not the log viewer. */
|
||||
const PREVIEW_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'pdf']);
|
||||
|
||||
const addLink = (filePath, matchIndex) => {
|
||||
const startCol = lineText.indexOf(filePath, matchIndex);
|
||||
if (startCol === -1) return;
|
||||
|
||||
const start = coordAt(startCol);
|
||||
const end = coordAt(startCol + filePath.length);
|
||||
// Skip if already have link at this position
|
||||
if (links.some((l) => l.range.start.x === startCol + 1)) return;
|
||||
if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return;
|
||||
|
||||
links.push({
|
||||
text: filePath,
|
||||
range: {
|
||||
start: { x: startCol + 1, y: bufferLineNumber }, // 1-based
|
||||
end: { x: startCol + filePath.length + 1, y: bufferLineNumber },
|
||||
},
|
||||
range: { start, end }, // 1-based, may span wrapped rows
|
||||
decorations: {
|
||||
pointerCursor: true,
|
||||
underline: true,
|
||||
},
|
||||
activate(event, text) {
|
||||
// Tailing a PNG in the log viewer shows binary noise; the file preview
|
||||
// already renders images and PDFs inline.
|
||||
const ext = (text.split('.').pop() || '').toLowerCase();
|
||||
if (PREVIEW_EXTS.has(ext)) {
|
||||
self.openFilePreview(text, self.activeSessionId);
|
||||
return;
|
||||
}
|
||||
self.openLogViewerWindow(text, self.activeSessionId);
|
||||
},
|
||||
hover() {
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* @fileoverview Web tabs: saved dashboard URLs rendered as tabs beside agent
|
||||
* sessions, so Codeman is one mission control instead of Codeman plus a pile of
|
||||
* browser tabs.
|
||||
*
|
||||
* Each open dashboard is an <iframe> inside #webviewLayer, which covers the
|
||||
* terminal while a web tab is active. Frames stay MOUNTED while hidden, because a
|
||||
* dashboard that reloads and re-authenticates on every tab switch is worse than
|
||||
* the browser tab it replaced. `maxLiveFrames` (from the server) bounds that with
|
||||
* least-recently-viewed eviction.
|
||||
*
|
||||
* Sandboxing: a proxied dashboard is served from Codeman's own origin, so the
|
||||
* iframe deliberately omits `allow-same-origin` unless the dashboard is marked
|
||||
* trusted. Without that omission the page could read this document and call the
|
||||
* API that spawns agents.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js, api-client.js, constants.js (escapeHtml)
|
||||
* @loadorder 12.5 of 16, after session-ui.js (needs the tab strip), before api-client.js
|
||||
*/
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Load the saved list and restore which tabs were open. */
|
||||
async initWebviews() {
|
||||
this.webviews = this.webviews || new Map();
|
||||
this.webviewOrder = this.webviewOrder || [];
|
||||
this.activeWebviewId = this.activeWebviewId || null;
|
||||
this._webviewMaxFrames = this._webviewMaxFrames || 6;
|
||||
this._webviewFrameLru = this._webviewFrameLru || [];
|
||||
|
||||
await this.refreshWebviews();
|
||||
|
||||
// Restore the previously open web tabs (per device: which dashboards you keep
|
||||
// open is a workspace-layout choice, not something to sync across machines).
|
||||
let saved = [];
|
||||
try {
|
||||
saved = JSON.parse(localStorage.getItem('codeman-webview-order') || '[]');
|
||||
} catch {
|
||||
saved = [];
|
||||
}
|
||||
this.webviewOrder = saved.filter((id) => this.webviews.has(id));
|
||||
this.renderSessionTabs();
|
||||
},
|
||||
|
||||
async refreshWebviews() {
|
||||
const data = await this._apiJson('/api/webviews');
|
||||
if (!data) return;
|
||||
this.webviews = new Map((data.webviews || []).map((w) => [w.id, w]));
|
||||
if (typeof data.maxLiveFrames === 'number') this._webviewMaxFrames = data.maxLiveFrames;
|
||||
this.renderWebviewMenuItems();
|
||||
},
|
||||
|
||||
/** SSE: the saved list changed (possibly on another device). */
|
||||
async _onWebviewChanged(data) {
|
||||
await this.refreshWebviews();
|
||||
// A dashboard deleted elsewhere must not linger as a dead tab here.
|
||||
if (data && data.action === 'deleted' && data.id) this._removeWebviewTab(data.id);
|
||||
this.renderSessionTabs();
|
||||
},
|
||||
|
||||
_persistWebviewOrder() {
|
||||
try {
|
||||
localStorage.setItem('codeman-webview-order', JSON.stringify(this.webviewOrder || []));
|
||||
} catch {
|
||||
/* private mode / quota, order is a convenience, never fatal */
|
||||
}
|
||||
},
|
||||
|
||||
// ── Tab strip ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Tab HTML for every OPEN web tab, appended by _fullRenderSessionTabs().
|
||||
* `startIndex` continues the Alt+N numbering after the session tabs.
|
||||
*/
|
||||
renderWebviewTabs(startIndex) {
|
||||
if (!this.webviewOrder || this.webviewOrder.length === 0) return '';
|
||||
const parts = [];
|
||||
let idx = startIndex;
|
||||
|
||||
for (const id of this.webviewOrder) {
|
||||
const webview = this.webviews.get(id);
|
||||
if (!webview) continue;
|
||||
const isActive = id === this.activeWebviewId;
|
||||
const jsonId = escapeHtml(JSON.stringify(id));
|
||||
const icon = webview.icon ? escapeHtml(webview.icon) : '';
|
||||
|
||||
parts.push(`<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
|
||||
onclick="app.handleWebviewTabClick(event, ${jsonId})"
|
||||
tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}"
|
||||
aria-label="${escapeHtml(webview.name)} web tab" title="${escapeHtml(webview.url)}">
|
||||
${idx < 9 ? '<span class="tab-number">' + (idx + 1) + '</span>' : ''}
|
||||
<span class="tab-web-icon" aria-hidden="true">${icon || this._webviewGlobeIcon()}</span>
|
||||
<span class="tab-info">
|
||||
<span class="tab-name-row">
|
||||
<span class="tab-name">${escapeHtml(webview.name)}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="tab-gear" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})" title="URL settings" aria-label="URL settings" tabindex="0">⚙</span>
|
||||
<span class="tab-close" onclick="event.stopPropagation(); app.closeWebviewTab(${jsonId})" title="Close tab" aria-label="Close web tab" tabindex="0">×</span>
|
||||
</div>`);
|
||||
idx++;
|
||||
}
|
||||
return parts.join('');
|
||||
},
|
||||
|
||||
_webviewGlobeIcon() {
|
||||
return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20"/></svg>';
|
||||
},
|
||||
|
||||
handleWebviewTabClick(event, id) {
|
||||
event?.preventDefault?.();
|
||||
return this.openWebview(id);
|
||||
},
|
||||
|
||||
/** Mark exactly one tab active across BOTH tab kinds. */
|
||||
_updateActiveWebviewTab() {
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container) return;
|
||||
for (const tab of container.querySelectorAll('.session-tab[data-webview-id]')) {
|
||||
tab.classList.toggle('active', tab.dataset.webviewId === this.activeWebviewId);
|
||||
}
|
||||
if (this.activeWebviewId) {
|
||||
// A web tab is active, so no session tab may also look active.
|
||||
for (const tab of container.querySelectorAll('.session-tab[data-id]')) tab.classList.remove('active');
|
||||
}
|
||||
},
|
||||
|
||||
// ── Opening / closing ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open (or focus) a dashboard tab. Mints a fresh capability every time: they are
|
||||
* memory-only and expire, so a tab reopened after a server restart must not reuse
|
||||
* the dead URL from the previous run.
|
||||
*/
|
||||
async openWebview(id) {
|
||||
const webview = this.webviews.get(id);
|
||||
if (!webview) return;
|
||||
|
||||
if (!this.webviewOrder.includes(id)) {
|
||||
this.webviewOrder.push(id);
|
||||
this._persistWebviewOrder();
|
||||
}
|
||||
|
||||
const data = await this._apiJson(`/api/webviews/${encodeURIComponent(id)}/open`, { method: 'POST' });
|
||||
if (!data) {
|
||||
this.showToast?.('Could not open URL', 'error');
|
||||
return;
|
||||
}
|
||||
if (data.webview) this.webviews.set(id, data.webview);
|
||||
|
||||
const src = data.embedUrl || data.webview?.url || webview.url;
|
||||
this._mountWebviewFrame(id, src, data.webview || webview);
|
||||
this.activeWebviewId = id;
|
||||
this.hideWelcome?.();
|
||||
document.querySelector('.main')?.classList.add('webview-active');
|
||||
this.renderSessionTabs();
|
||||
this._updateActiveWebviewTab();
|
||||
},
|
||||
|
||||
/** Create the frame if absent, then reveal it and hide its siblings. */
|
||||
_mountWebviewFrame(id, src, webview) {
|
||||
const layer = document.getElementById('webviewLayer');
|
||||
if (!layer) return;
|
||||
|
||||
let wrap = layer.querySelector(`.webview-frame[data-webview-id="${CSS.escape(id)}"]`);
|
||||
if (!wrap) {
|
||||
wrap = document.createElement('div');
|
||||
wrap.className = 'webview-frame';
|
||||
wrap.dataset.webviewId = id;
|
||||
|
||||
const frame = document.createElement('iframe');
|
||||
frame.className = 'webview-iframe';
|
||||
frame.setAttribute('title', webview.name);
|
||||
// No allow-same-origin unless explicitly trusted: a proxied page is served
|
||||
// from THIS origin, so granting it would let the dashboard read this document
|
||||
// and drive the Codeman API.
|
||||
const sandbox = ['allow-scripts', 'allow-forms', 'allow-popups', 'allow-downloads', 'allow-modals'];
|
||||
if (webview.trusted) sandbox.push('allow-same-origin');
|
||||
frame.setAttribute('sandbox', sandbox.join(' '));
|
||||
frame.setAttribute('referrerpolicy', 'no-referrer-when-downgrade');
|
||||
frame.src = src;
|
||||
|
||||
const failure = document.createElement('div');
|
||||
failure.className = 'webview-failure';
|
||||
failure.innerHTML = this._webviewFailureHtml(id);
|
||||
|
||||
wrap.appendChild(frame);
|
||||
wrap.appendChild(failure);
|
||||
layer.appendChild(wrap);
|
||||
|
||||
// A frame that never fires `load` is the normal symptom of a refused embed or
|
||||
// an unreachable host. Show an actionable panel instead of a blank rectangle.
|
||||
const timer = setTimeout(() => wrap.classList.add('webview-frame--failed'), 8000);
|
||||
frame.addEventListener('load', () => {
|
||||
clearTimeout(timer);
|
||||
wrap.classList.remove('webview-frame--failed');
|
||||
});
|
||||
}
|
||||
|
||||
this._touchWebviewFrame(id);
|
||||
for (const other of layer.querySelectorAll('.webview-frame')) {
|
||||
other.classList.toggle('active', other.dataset.webviewId === id);
|
||||
}
|
||||
// Chart libraries measure on resize; a frame revealed from display:none needs the nudge.
|
||||
requestAnimationFrame(() => window.dispatchEvent(new Event('resize')));
|
||||
},
|
||||
|
||||
_webviewFailureHtml(id) {
|
||||
const jsonId = escapeHtml(JSON.stringify(id));
|
||||
return `<div class="webview-failure-inner">
|
||||
<h3>This URL did not load</h3>
|
||||
<p>It may be unreachable from the Codeman server, or it may refuse to be embedded.</p>
|
||||
<div class="webview-failure-actions">
|
||||
<button class="btn-secondary" onclick="app.reloadWebview(${jsonId})">Reload</button>
|
||||
<button class="btn-secondary" onclick="app.openWebviewExternal(${jsonId})">Open in new tab</button>
|
||||
<button class="btn-secondary" onclick="app.showWebviewModal(${jsonId})">Edit</button>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
/** Least-recently-viewed eviction so N open dashboards cannot pin N live pages. */
|
||||
_touchWebviewFrame(id) {
|
||||
this._webviewFrameLru = (this._webviewFrameLru || []).filter((x) => x !== id);
|
||||
this._webviewFrameLru.push(id);
|
||||
const layer = document.getElementById('webviewLayer');
|
||||
if (!layer) return;
|
||||
while (this._webviewFrameLru.length > this._webviewMaxFrames) {
|
||||
const evict = this._webviewFrameLru.shift();
|
||||
if (evict === this.activeWebviewId) continue;
|
||||
layer.querySelector(`.webview-frame[data-webview-id="${CSS.escape(evict)}"]`)?.remove();
|
||||
}
|
||||
},
|
||||
|
||||
reloadWebview(id) {
|
||||
const target = id || this.activeWebviewId;
|
||||
if (!target) return;
|
||||
document
|
||||
.getElementById('webviewLayer')
|
||||
?.querySelector(`.webview-frame[data-webview-id="${CSS.escape(target)}"]`)
|
||||
?.remove();
|
||||
this._webviewFrameLru = (this._webviewFrameLru || []).filter((x) => x !== target);
|
||||
return this.openWebview(target);
|
||||
},
|
||||
|
||||
openWebviewExternal(id) {
|
||||
const webview = this.webviews.get(id || this.activeWebviewId);
|
||||
if (webview) window.open(webview.url, '_blank', 'noopener');
|
||||
},
|
||||
|
||||
closeWebviewTab(id) {
|
||||
this._removeWebviewTab(id);
|
||||
this.renderSessionTabs();
|
||||
},
|
||||
|
||||
_removeWebviewTab(id) {
|
||||
this.webviewOrder = (this.webviewOrder || []).filter((x) => x !== id);
|
||||
this._webviewFrameLru = (this._webviewFrameLru || []).filter((x) => x !== id);
|
||||
this._persistWebviewOrder();
|
||||
document
|
||||
.getElementById('webviewLayer')
|
||||
?.querySelector(`.webview-frame[data-webview-id="${CSS.escape(id)}"]`)
|
||||
?.remove();
|
||||
|
||||
if (this.activeWebviewId === id) {
|
||||
this.activeWebviewId = null;
|
||||
const next = this.webviewOrder[0];
|
||||
if (next) {
|
||||
this.openWebview(next);
|
||||
} else {
|
||||
this._hideWebviewLayer();
|
||||
// Fall back to whatever session was last shown, or the welcome screen.
|
||||
if (this.activeSessionId) this._updateActiveTabImmediate(this.activeSessionId);
|
||||
else this.showWelcome?.();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** Called by selectSession(): a session tab takes the stage back from a web tab. */
|
||||
_hideWebviewLayer() {
|
||||
if (!this.activeWebviewId && !document.querySelector('.main.webview-active')) return;
|
||||
this.activeWebviewId = null;
|
||||
document.querySelector('.main')?.classList.remove('webview-active');
|
||||
for (const frame of document.querySelectorAll('#webviewLayer .webview-frame')) {
|
||||
frame.classList.remove('active');
|
||||
}
|
||||
this._updateActiveWebviewTab();
|
||||
},
|
||||
|
||||
// ── Run-menu entries ──────────────────────────────────────────────────────
|
||||
|
||||
/** Saved dashboards listed inside the Run dropdown, under "Web / URL". */
|
||||
renderWebviewMenuItems() {
|
||||
const container = document.getElementById('runModeWebviews');
|
||||
if (!container) return;
|
||||
const list = [...(this.webviews?.values() || [])];
|
||||
if (list.length === 0) {
|
||||
container.innerHTML = '<div class="run-mode-empty">No URLs yet</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = list
|
||||
.map(
|
||||
(w) => `<button class="run-mode-option run-mode-option--web" onclick="app.openWebviewFromMenu(${escapeHtml(
|
||||
JSON.stringify(w.id)
|
||||
)})" title="${escapeHtml(w.url)}">
|
||||
<span class="run-mode-menu-icon">${w.icon ? escapeHtml(w.icon) : '<span class="run-mode-dot web"></span>'}</span>${escapeHtml(
|
||||
w.name
|
||||
)}
|
||||
</button>`
|
||||
)
|
||||
.join('');
|
||||
},
|
||||
|
||||
openWebviewFromMenu(id) {
|
||||
document.getElementById('runModeMenu')?.classList.remove('active');
|
||||
return this.openWebview(id);
|
||||
},
|
||||
|
||||
// ── Icon picker ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Common dashboard/service glyphs. The text field stays open for anything else. */
|
||||
_webviewIconChoices() {
|
||||
return ['📊', '📈', '🖥️', '🎛️', '📡', '🐳', '🗄️', '🔒', '🌐', '📁', '🧪', '🧬', '⚡', '🔔', '📝', '🎧'];
|
||||
},
|
||||
|
||||
_renderWebviewIconPicker(selected) {
|
||||
const picker = document.getElementById('webviewIconPicker');
|
||||
if (!picker) return;
|
||||
picker.innerHTML = this._webviewIconChoices()
|
||||
.map(
|
||||
(icon) =>
|
||||
`<button type="button" class="webview-icon-choice${icon === selected ? ' selected' : ''}"
|
||||
onclick="app.pickWebviewIcon(${escapeHtml(JSON.stringify(icon))})"
|
||||
aria-label="Use ${escapeHtml(icon)} as the icon">${escapeHtml(icon)}</button>`
|
||||
)
|
||||
.join('');
|
||||
},
|
||||
|
||||
/** Clicking the selected icon again clears it, so there is a way back to no icon. */
|
||||
pickWebviewIcon(icon) {
|
||||
const field = document.getElementById('webviewIcon');
|
||||
if (!field) return;
|
||||
field.value = field.value === icon ? '' : icon;
|
||||
this._renderWebviewIconPicker(field.value);
|
||||
},
|
||||
|
||||
// ── Editor modal ──────────────────────────────────────────────────────────
|
||||
|
||||
showWebviewModal(id) {
|
||||
const modal = document.getElementById('webviewModal');
|
||||
if (!modal) return;
|
||||
const webview = id ? this.webviews.get(id) : null;
|
||||
this._editingWebviewId = webview ? webview.id : null;
|
||||
|
||||
document.getElementById('webviewModalTitle').textContent = webview ? 'Edit URL' : 'Add URL';
|
||||
this._renderWebviewIconPicker(webview?.icon || '');
|
||||
document.getElementById('webviewName').value = webview?.name || '';
|
||||
document.getElementById('webviewUrl').value = webview?.url || '';
|
||||
document.getElementById('webviewIcon').value = webview?.icon || '';
|
||||
document.getElementById('webviewSandboxed').checked = !webview?.trusted;
|
||||
document.getElementById('webviewProbeResult').textContent = '';
|
||||
document.getElementById('webviewDeleteBtn').style.display = webview ? '' : 'none';
|
||||
|
||||
document.getElementById('runModeMenu')?.classList.remove('active');
|
||||
modal.classList.add('active');
|
||||
document.getElementById('webviewName').focus();
|
||||
},
|
||||
|
||||
closeWebviewModal() {
|
||||
document.getElementById('webviewModal')?.classList.remove('active');
|
||||
this._editingWebviewId = null;
|
||||
},
|
||||
|
||||
/** Server-side probe: it runs from the network position the proxy will use. */
|
||||
async testWebviewUrl() {
|
||||
const url = document.getElementById('webviewUrl').value.trim();
|
||||
const out = document.getElementById('webviewProbeResult');
|
||||
if (!url) {
|
||||
out.textContent = 'Enter a URL first.';
|
||||
return;
|
||||
}
|
||||
out.textContent = 'Testing...';
|
||||
const probe = await this._apiJson('/api/webviews/probe', { method: 'POST', body: { url } });
|
||||
if (!probe) {
|
||||
out.textContent = 'Test failed (invalid URL?).';
|
||||
return;
|
||||
}
|
||||
out.textContent = probe.reachable
|
||||
? `Reachable (HTTP ${probe.status}). ${probe.reason}`
|
||||
: `Not reachable. ${probe.reason}`;
|
||||
out.className = 'form-hint webview-probe-result ' + (probe.reachable ? 'ok' : 'bad');
|
||||
},
|
||||
|
||||
async saveWebview() {
|
||||
const name = document.getElementById('webviewName').value.trim();
|
||||
const url = document.getElementById('webviewUrl').value.trim();
|
||||
const icon = document.getElementById('webviewIcon').value.trim();
|
||||
const trusted = !document.getElementById('webviewSandboxed').checked;
|
||||
if (!name || !url) {
|
||||
this.showToast?.('Name and URL are required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// `icon: undefined` rather than null, the schema uses .optional(), which
|
||||
// rejects an explicit null on the wire.
|
||||
const body = { name, url, icon: icon || undefined, trusted };
|
||||
const editing = this._editingWebviewId;
|
||||
const data = editing
|
||||
? await this._apiJson(`/api/webviews/${encodeURIComponent(editing)}`, { method: 'PATCH', body })
|
||||
: await this._apiJson('/api/webviews', { method: 'POST', body });
|
||||
|
||||
if (!data) {
|
||||
this.showToast?.('Could not save (check the URL)', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.refreshWebviews();
|
||||
this.closeWebviewModal();
|
||||
if (editing) {
|
||||
// The capability was revoked server-side by the edit, so a mounted frame is
|
||||
// now pointing at a dead URL. Remount it.
|
||||
if (this.webviewOrder.includes(editing)) this.reloadWebview(editing);
|
||||
} else {
|
||||
this.openWebview(data.id);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteWebview() {
|
||||
const id = this._editingWebviewId;
|
||||
if (!id) return;
|
||||
const webview = this.webviews.get(id);
|
||||
if (!confirm(`Delete "${webview?.name || id}"?`)) return;
|
||||
const res = await this._apiDelete(`/api/webviews/${encodeURIComponent(id)}`);
|
||||
if (!res || !res.ok) {
|
||||
this.showToast?.('Could not delete URL', 'error');
|
||||
return;
|
||||
}
|
||||
this._removeWebviewTab(id);
|
||||
this.webviews.delete(id);
|
||||
this.closeWebviewModal();
|
||||
this.renderWebviewMenuItems();
|
||||
this.renderSessionTabs();
|
||||
},
|
||||
});
|
||||
@@ -22,3 +22,4 @@ export { registerSearchRoutes } from './search-routes.js';
|
||||
export { registerMeRoutes } from './me-routes.js';
|
||||
export { registerAdminRoutes } from './admin-routes.js';
|
||||
export { registerWsRoutes } from './ws-routes.js';
|
||||
export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
/**
|
||||
* @fileoverview Web tabs: saved dashboard URLs, plus the reverse proxy that makes
|
||||
* them embeddable.
|
||||
*
|
||||
* Two distinct surfaces live here, and the split matters:
|
||||
*
|
||||
* 1. `/api/webviews/*`, ordinary authenticated CRUD, owner-scoped like every
|
||||
* other resource, returning the `ApiResponse` envelope.
|
||||
* 2. `/webview/:cap/*`, the proxy. NOT an API surface. It authenticates on an
|
||||
* unguessable capability in the path instead of Codeman's session cookie, and
|
||||
* is correspondingly exempt from the cookie and Origin checks in
|
||||
* `middleware/auth.ts`. See `src/webview-capabilities.ts` for why a cookie
|
||||
* cannot work here (sandboxed iframes are opaque-origin, so their requests are
|
||||
* cross-site and arrive with `Origin: null`).
|
||||
*
|
||||
* The proxy is registered inside an ENCAPSULATED plugin scope with its own
|
||||
* catch-all content-type parser. Fastify scopes parsers to the plugin that
|
||||
* registers them, which is what lets the proxy forward raw request bodies
|
||||
* upstream while the rest of the app keeps its JSON parsing (and, critically,
|
||||
* keeps `text/plain` raw, auto-parsing that was a real CSRF hole once).
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /api/webviews
|
||||
* POST /api/webviews
|
||||
* PATCH /api/webviews/:id
|
||||
* DELETE /api/webviews/:id
|
||||
* POST /api/webviews/probe
|
||||
* POST /api/webviews/:id/open
|
||||
* ALL /webview/:cap/* (+ WebSocket upgrade on GET)
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { WebSocket as WsClient } from 'ws';
|
||||
import type { WebSocket } from 'ws';
|
||||
import { getDataDir } from '../../config/instance.js';
|
||||
import {
|
||||
MAX_LIVE_WEBVIEW_FRAMES,
|
||||
MAX_WEBVIEWS,
|
||||
MAX_WEBVIEW_HTML_REWRITE_BYTES,
|
||||
MAX_WEBVIEW_SOCKETS,
|
||||
WEBVIEW_PROBE_TIMEOUT_MS,
|
||||
WEBVIEW_PROXY_PREFIX,
|
||||
WEBVIEW_UPSTREAM_TIMEOUT_MS,
|
||||
} from '../../config/webview-limits.js';
|
||||
import { readWebviews, writeWebviews } from '../../webview-store.js';
|
||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import type { Webview, WebviewOpenData, WebviewProbe } from '../../types.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import { canAccessOwned, getAuthUser, ownerFor, parseBody } from '../route-helpers.js';
|
||||
import { WebviewCreateSchema, WebviewProbeSchema, WebviewUpdateSchema } from '../schemas.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort } from '../ports/index.js';
|
||||
import {
|
||||
buildDownstreamResponseHeaders,
|
||||
buildProxyCorsHeaders,
|
||||
buildUpstreamRequestHeaders,
|
||||
capabilityFromReferer,
|
||||
extractFrameAncestors,
|
||||
isFramableCrossOrigin,
|
||||
isHtmlContentType,
|
||||
parseWebviewUrl,
|
||||
proxyPrefixFor,
|
||||
resolveUpstreamUrl,
|
||||
rewriteHtml,
|
||||
upstreamWebSocketUrl,
|
||||
} from '../webview-proxy.js';
|
||||
|
||||
/**
|
||||
* Resolved per call rather than captured at module load. `getDataDir()` reads
|
||||
* `CODEMAN_DATA_DIR` each time, so a lazy lookup keeps tests writing to a temp dir
|
||||
* instead of the developer's real `~/.codeman/webviews.json`.
|
||||
*/
|
||||
function configDir(): string {
|
||||
return getDataDir();
|
||||
}
|
||||
|
||||
/** Live proxied WebSockets per webview id, so one dashboard cannot exhaust the socket budget. */
|
||||
const socketCounts = new Map<string, number>();
|
||||
|
||||
interface ProxyParams {
|
||||
cap: string;
|
||||
'*'?: string;
|
||||
}
|
||||
|
||||
/** Serialize webview mutations: read-modify-write on a shared JSON file otherwise races. */
|
||||
let writeChain: Promise<unknown> = Promise.resolve();
|
||||
function withWebviews<T>(fn: (list: Webview[]) => Promise<T> | T): Promise<T> {
|
||||
const next = writeChain.then(async () => {
|
||||
const list = await readWebviews(configDir());
|
||||
return fn(list);
|
||||
});
|
||||
// Keep the chain alive even if this link rejects, or every later write deadlocks.
|
||||
writeChain = next.catch(() => undefined);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort): void {
|
||||
registerCrudRoutes(app, ctx);
|
||||
registerProxyRoutes(app);
|
||||
}
|
||||
|
||||
// ───────────────────────────── CRUD ─────────────────────────────
|
||||
|
||||
function registerCrudRoutes(app: FastifyInstance, ctx: EventPort): void {
|
||||
app.get('/api/webviews', async (req) => {
|
||||
const user = getAuthUser(req);
|
||||
const all = await readWebviews(configDir());
|
||||
const webviews = all.filter((w) => canAccessOwned(user, w.owner));
|
||||
return { success: true, data: { webviews, maxLiveFrames: MAX_LIVE_WEBVIEW_FRAMES } };
|
||||
});
|
||||
|
||||
app.post('/api/webviews', async (req, reply) => {
|
||||
const input = parseBody(WebviewCreateSchema, req.body);
|
||||
const owner = ownerFor(req);
|
||||
const user = getAuthUser(req);
|
||||
|
||||
const created = await withWebviews(async (list) => {
|
||||
const mine = list.filter((w) => canAccessOwned(user, w.owner));
|
||||
if (mine.length >= MAX_WEBVIEWS) return null;
|
||||
|
||||
const webview: Webview = {
|
||||
id: randomUUID(),
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
icon: input.icon,
|
||||
// Proxy is the safe default: it is the only mode that works for a plain-HTTP
|
||||
// dashboard on an HTTPS Codeman, which is the common case.
|
||||
embedMode: input.embedMode ?? 'proxy',
|
||||
trusted: input.trusted ?? false,
|
||||
owner,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
list.push(webview);
|
||||
await writeWebviews(configDir(), list);
|
||||
return webview;
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, `Webview limit reached (max ${MAX_WEBVIEWS})`));
|
||||
}
|
||||
|
||||
ctx.broadcast(SseEvent.WebviewChanged, { action: 'created', id: created.id });
|
||||
return { success: true, data: created };
|
||||
});
|
||||
|
||||
app.patch<{ Params: { id: string } }>('/api/webviews/:id', async (req, reply) => {
|
||||
const input = parseBody(WebviewUpdateSchema, req.body);
|
||||
const user = getAuthUser(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const updated = await withWebviews(async (list) => {
|
||||
const index = list.findIndex((w) => w.id === id);
|
||||
if (index === -1) return 'not-found' as const;
|
||||
if (!canAccessOwned(user, list[index].owner)) return 'forbidden' as const;
|
||||
|
||||
const next: Webview = { ...list[index], ...input };
|
||||
list[index] = next;
|
||||
await writeWebviews(configDir(), list);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (updated === 'not-found') {
|
||||
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Webview not found'));
|
||||
}
|
||||
if (updated === 'forbidden') {
|
||||
return reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN, 'Not your webview'));
|
||||
}
|
||||
|
||||
// Any edit invalidates the outstanding capability. Otherwise a token minted
|
||||
// against the OLD url keeps proxying to it after the user repointed the tab.
|
||||
webviewCapabilities.revokeWebview(id);
|
||||
ctx.broadcast(SseEvent.WebviewChanged, { action: 'updated', id });
|
||||
return { success: true, data: updated };
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/webviews/:id', async (req, reply) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await withWebviews(async (list) => {
|
||||
const index = list.findIndex((w) => w.id === id);
|
||||
if (index === -1) return 'not-found' as const;
|
||||
if (!canAccessOwned(user, list[index].owner)) return 'forbidden' as const;
|
||||
list.splice(index, 1);
|
||||
await writeWebviews(configDir(), list);
|
||||
return 'deleted' as const;
|
||||
});
|
||||
|
||||
if (result === 'not-found') {
|
||||
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Webview not found'));
|
||||
}
|
||||
if (result === 'forbidden') {
|
||||
return reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN, 'Not your webview'));
|
||||
}
|
||||
|
||||
webviewCapabilities.revokeWebview(id);
|
||||
socketCounts.delete(id);
|
||||
ctx.broadcast(SseEvent.WebviewChanged, { action: 'deleted', id });
|
||||
return { success: true, data: { id } };
|
||||
});
|
||||
|
||||
/**
|
||||
* Reachability + framing probe for the editor's "Test" button.
|
||||
*
|
||||
* Runs from the SERVER, which is the network position the proxy will use, so a
|
||||
* green result here means the proxy will actually work. Never throws upstream
|
||||
* failures at the caller: an unreachable dashboard is a normal answer, not a 500.
|
||||
*/
|
||||
app.post('/api/webviews/probe', async (req) => {
|
||||
const { url } = parseBody(WebviewProbeSchema, req.body);
|
||||
return { success: true, data: await probeUrl(url) };
|
||||
});
|
||||
|
||||
/**
|
||||
* Mint the capability the iframe will load. Separate from GET /api/webviews so a
|
||||
* capability exists only for dashboards actually opened, and so the TTL clock
|
||||
* starts on open rather than on page load.
|
||||
*/
|
||||
app.post<{ Params: { id: string } }>('/api/webviews/:id/open', async (req, reply) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const webview = await withWebviews(async (list) => {
|
||||
const index = list.findIndex((w) => w.id === id);
|
||||
if (index === -1) return 'not-found' as const;
|
||||
if (!canAccessOwned(user, list[index].owner)) return 'forbidden' as const;
|
||||
list[index] = { ...list[index], lastOpenedAt: Date.now() };
|
||||
await writeWebviews(configDir(), list);
|
||||
return list[index];
|
||||
});
|
||||
|
||||
if (webview === 'not-found') {
|
||||
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Webview not found'));
|
||||
}
|
||||
if (webview === 'forbidden') {
|
||||
return reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN, 'Not your webview'));
|
||||
}
|
||||
|
||||
// Direct mode has no capability to mint: the iframe loads the real URL.
|
||||
if (webview.embedMode === 'direct') {
|
||||
const data: WebviewOpenData = { webview };
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
const capability = webviewCapabilities.mint(webview.id, webview.owner);
|
||||
const data: WebviewOpenData = { webview, embedUrl: proxyPrefixFor(capability) };
|
||||
return { success: true, data };
|
||||
});
|
||||
}
|
||||
|
||||
async function probeUrl(url: string): Promise<WebviewProbe> {
|
||||
const target = parseWebviewUrl(url);
|
||||
if (!target) {
|
||||
return {
|
||||
reachable: false,
|
||||
framable: false,
|
||||
recommendedMode: 'proxy',
|
||||
reason: 'Invalid URL',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(target.href, {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(WEBVIEW_PROBE_TIMEOUT_MS),
|
||||
});
|
||||
// The body is irrelevant to the probe; release the socket rather than leak it.
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
|
||||
const xFrameOptions = response.headers.get('x-frame-options') ?? undefined;
|
||||
const csp = response.headers.get('content-security-policy') ?? undefined;
|
||||
const frameAncestors = extractFrameAncestors(csp);
|
||||
const framable = isFramableCrossOrigin(xFrameOptions, csp);
|
||||
const isHttp = target.protocol === 'http:';
|
||||
|
||||
// Direct embedding is only viable for an HTTPS target that permits framing:
|
||||
// an HTTPS Codeman page cannot embed http:// at all (mixed content).
|
||||
const recommendedMode = !isHttp && framable ? 'direct' : 'proxy';
|
||||
const reason = isHttp
|
||||
? 'Plain HTTP: an HTTPS Codeman page cannot embed it directly, so it is proxied.'
|
||||
: framable
|
||||
? 'Reachable and allows framing: can be embedded directly.'
|
||||
: 'Reachable but refuses framing, so it is proxied.';
|
||||
|
||||
return {
|
||||
reachable: true,
|
||||
status: response.status,
|
||||
xFrameOptions,
|
||||
frameAncestors,
|
||||
framable,
|
||||
recommendedMode,
|
||||
reason,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
reachable: false,
|
||||
framable: false,
|
||||
recommendedMode: 'proxy',
|
||||
reason: `Server could not reach it: ${message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────── Proxy ─────────────────────────────
|
||||
|
||||
function registerProxyRoutes(app: FastifyInstance): void {
|
||||
app.register(async (scope) => {
|
||||
// Encapsulated to this plugin only. The proxy must relay request bodies
|
||||
// BYTE-FOR-BYTE, so every parser is replaced with a pass-through that hands
|
||||
// back the raw stream. Doing this on the root instance would break JSON
|
||||
// routes and un-fix the text/plain CSRF hardening.
|
||||
scope.removeAllContentTypeParsers();
|
||||
scope.addContentTypeParser('*', (_req, payload, done) => done(null, payload));
|
||||
|
||||
// A single GET route serving both roles: `handler` for normal requests,
|
||||
// `wsHandler` for upgrades. Registering them as two routes on one URL would
|
||||
// collide.
|
||||
scope.route<{ Params: ProxyParams }>({
|
||||
method: 'GET',
|
||||
url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`,
|
||||
handler: proxyHttp,
|
||||
wsHandler: proxyWebSocket,
|
||||
});
|
||||
|
||||
// HEAD is deliberately absent: Fastify's `exposeHeadRoutes` already derives a
|
||||
// HEAD route from the GET above, and declaring it again is a startup error.
|
||||
scope.route<{ Params: ProxyParams }>({
|
||||
method: ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`,
|
||||
handler: proxyHttp,
|
||||
});
|
||||
|
||||
// `/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
|
||||
// dashboard's HTML resolve inside the prefix instead of one level above it.
|
||||
scope.get<{ Params: { cap: string } }>(`${WEBVIEW_PROXY_PREFIX}/:cap`, (req, reply) => {
|
||||
return reply.redirect(proxyPrefixFor(req.params.cap), 302);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve a capability to its live webview record, or null. */
|
||||
async function lookupCapability(capability: string): Promise<Webview | null> {
|
||||
const record = webviewCapabilities.resolve(capability);
|
||||
if (!record) return null;
|
||||
const list = await readWebviews(configDir());
|
||||
const webview = list.find((w) => w.id === record.webviewId);
|
||||
if (!webview) return null;
|
||||
// The capability is bound to the identity that minted it; an ownership change
|
||||
// on the record must not leave a stale token working.
|
||||
if (webview.owner !== record.owner) return null;
|
||||
return webview;
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠ Every exit path RETURNS `reply.send(...)`.
|
||||
*
|
||||
* This handler is `async`, and Fastify resolves an async handler's promise as the
|
||||
* response. `reply.send(stream)` followed by a bare `return` resolves to
|
||||
* `undefined` before the stream has been consumed, and Fastify then answers with
|
||||
* an EMPTY body: HTML (a synchronously-set string payload) survives it, every
|
||||
* streamed asset comes back zero-length. Returning the reply is what tells Fastify
|
||||
* the response is already owned by this handler.
|
||||
*/
|
||||
function proxyHttp(req: FastifyRequest<{ Params: ProxyParams }>, reply: FastifyReply): Promise<FastifyReply> {
|
||||
return proxyRequest(req, reply, req.params.cap, req.params['*'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy one request to the dashboard behind `cap`, serving `wildcard` as the
|
||||
* upstream path. Split out from the route handler so the 404 fallback (which has
|
||||
* no route params) can reuse it.
|
||||
*/
|
||||
async function proxyRequest(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
cap: string,
|
||||
wildcard: string
|
||||
): Promise<FastifyReply> {
|
||||
const webview = await lookupCapability(cap);
|
||||
if (!webview) {
|
||||
return reply.code(403).type('text/plain').send('Forbidden: unknown or expired webview capability');
|
||||
}
|
||||
|
||||
// CORS is required even though the URL is on this host: a sandboxed dashboard is
|
||||
// opaque-origin, so its fetch/XHR are cross-origin requests. See
|
||||
// buildProxyCorsHeaders.
|
||||
const cors = buildProxyCorsHeaders(
|
||||
typeof req.headers.origin === 'string' ? req.headers.origin : undefined,
|
||||
typeof req.headers['access-control-request-headers'] === 'string'
|
||||
? req.headers['access-control-request-headers']
|
||||
: undefined
|
||||
);
|
||||
|
||||
// Answer the preflight here rather than relaying it: the dashboard has no reason
|
||||
// to know it is being framed, and most would reject an unexpected `Origin: null`.
|
||||
if (req.method === 'OPTIONS' && req.headers['access-control-request-method']) {
|
||||
for (const [key, value] of Object.entries(cors)) reply.header(key, value);
|
||||
return reply.code(204).send();
|
||||
}
|
||||
|
||||
const queryStart = req.url.indexOf('?');
|
||||
const search = queryStart === -1 ? '' : req.url.slice(queryStart);
|
||||
const upstream = resolveUpstreamUrl(webview.url, wildcard, search);
|
||||
if (!upstream) {
|
||||
return reply.code(400).type('text/plain').send('Bad Request: path escapes the dashboard origin');
|
||||
}
|
||||
|
||||
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
|
||||
const headers = buildUpstreamRequestHeaders(req.headers, upstream, {
|
||||
forwardCookies: webview.trusted,
|
||||
sessionCookieName: AUTH_COOKIE_NAME,
|
||||
refererPath: typeof req.headers.referer === 'string' ? stripProxyPrefix(req.headers.referer, cap) : undefined,
|
||||
});
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(upstream.href, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: hasBody ? (req.body as Readable) : undefined,
|
||||
// Required by undici whenever the body is a stream.
|
||||
...(hasBody ? { duplex: 'half' } : {}),
|
||||
// Redirects are rewritten into the proxy prefix instead of followed, so the
|
||||
// browser's URL stays inside the frame and relative assets keep resolving.
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(WEBVIEW_UPSTREAM_TIMEOUT_MS),
|
||||
} as RequestInit);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return reply.code(502).type('text/plain').send(`Dashboard unreachable: ${message}`);
|
||||
}
|
||||
|
||||
const secureContext = req.protocol === 'https';
|
||||
const {
|
||||
headers: outHeaders,
|
||||
setCookie,
|
||||
csp,
|
||||
} = buildDownstreamResponseHeaders(
|
||||
response.headers as unknown as Iterable<[string, string]>,
|
||||
response.headers.getSetCookie(),
|
||||
cap,
|
||||
upstream,
|
||||
secureContext
|
||||
);
|
||||
|
||||
reply.code(response.status);
|
||||
for (const [key, value] of Object.entries(outHeaders)) reply.header(key, value);
|
||||
// After the upstream headers, so ours win: an upstream ACAO would name the
|
||||
// dashboard's own origin, not the opaque origin this frame actually has.
|
||||
for (const [key, value] of Object.entries(cors)) reply.header(key, value);
|
||||
for (const cookie of setCookie) reply.header('set-cookie', cookie);
|
||||
|
||||
// registerSecurityHeaders already stamped Codeman's own `default-src 'self'`
|
||||
// policy on this reply during onRequest. Left in place it breaks essentially
|
||||
// every dashboard (inline scripts, CDN assets), so it is replaced by the
|
||||
// upstream's own policy, or removed when the upstream had none.
|
||||
if (csp) reply.header('content-security-policy', csp);
|
||||
else reply.removeHeader('content-security-policy');
|
||||
|
||||
if (!response.body || req.method === 'HEAD') {
|
||||
return reply.send();
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? undefined;
|
||||
const declaredLength = Number(response.headers.get('content-length') ?? '0');
|
||||
const rewritable = isHtmlContentType(contentType) && declaredLength <= MAX_WEBVIEW_HTML_REWRITE_BYTES;
|
||||
|
||||
if (rewritable) {
|
||||
// Buffer only HTML, only under the cap: `<base>` injection needs the whole
|
||||
// document, and buffering an unbounded upstream body is a memory hazard.
|
||||
const html = await response.text();
|
||||
return reply.send(html.length <= MAX_WEBVIEW_HTML_REWRITE_BYTES ? rewriteHtml(html, cap) : html);
|
||||
}
|
||||
|
||||
return reply.send(Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-resort handler for a dashboard asset requested with a ROOT-ABSOLUTE URL.
|
||||
*
|
||||
* `<base href>` fixes relative URLs and the HTML rewrite fixes `src`/`href`/`action`
|
||||
* attributes, but neither can reach a URL built at runtime: `fetch('/api/data')`,
|
||||
* `import('/chunk.js')`, `url(/img.png)` inside a stylesheet. Those arrive at
|
||||
* Codeman's root and 404.
|
||||
*
|
||||
* The `Referer` identifies which dashboard asked, so the request can be routed to
|
||||
* the right upstream. Wiring it into the 404 handler rather than a catch-all route
|
||||
* is what keeps it contained: every real Codeman route matches first, and this only
|
||||
* ever sees requests that were going to fail anyway.
|
||||
*
|
||||
* @returns true when the request was handled (caller must not also reply).
|
||||
*/
|
||||
export async function tryWebviewRefererFallback(req: FastifyRequest, reply: FastifyReply): Promise<boolean> {
|
||||
// Safe methods only. A write arriving here has already lost its raw body to the
|
||||
// root instance's JSON parser, so it could not be relayed faithfully anyway.
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
|
||||
|
||||
const capability = capabilityFromReferer(typeof req.headers.referer === 'string' ? req.headers.referer : undefined);
|
||||
if (!capability) return false;
|
||||
if (!webviewCapabilities.resolve(capability)) return false;
|
||||
|
||||
const path = req.url.split('?')[0].replace(/^\//, '');
|
||||
await proxyRequest(req, reply, capability, path);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Turn a proxy-side Referer back into the upstream path it corresponds to. */
|
||||
function stripProxyPrefix(referer: string, capability: string): string | undefined {
|
||||
try {
|
||||
const url = new URL(referer);
|
||||
const prefix = proxyPrefixFor(capability);
|
||||
if (!url.pathname.startsWith(prefix)) return undefined;
|
||||
return `/${url.pathname.slice(prefix.length)}${url.search}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────── WebSocket ───────────────────────────
|
||||
|
||||
/**
|
||||
* Relay a WebSocket through to the dashboard.
|
||||
*
|
||||
* Live dashboards (Grafana, Home Assistant, Uptime Kuma) push over WebSocket, so
|
||||
* without this leg they load but their realtime panels stay permanently empty.
|
||||
*
|
||||
* The upgrade is guarded on the capability, NOT on `Origin`: a sandboxed iframe is
|
||||
* opaque-origin, so its upgrade arrives with `Origin: null`. The host allowlist
|
||||
* still applies (it runs in the global onRequest hook), so DNS-rebinding
|
||||
* protection is unaffected.
|
||||
*/
|
||||
function proxyWebSocket(socket: WebSocket, req: FastifyRequest<{ Params: ProxyParams }>): void {
|
||||
const { cap } = req.params;
|
||||
|
||||
void (async () => {
|
||||
const webview = await lookupCapability(cap);
|
||||
if (!webview) {
|
||||
socket.close(4003, 'Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const live = socketCounts.get(webview.id) ?? 0;
|
||||
if (live >= MAX_WEBVIEW_SOCKETS) {
|
||||
socket.close(4008, 'Too many connections');
|
||||
return;
|
||||
}
|
||||
|
||||
const wildcard = req.params['*'] ?? '';
|
||||
const queryStart = req.url.indexOf('?');
|
||||
const search = queryStart === -1 ? '' : req.url.slice(queryStart);
|
||||
const upstream = resolveUpstreamUrl(webview.url, wildcard, search);
|
||||
if (!upstream) {
|
||||
socket.close(4003, 'Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
socketCounts.set(webview.id, live + 1);
|
||||
let released = false;
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
const count = socketCounts.get(webview.id) ?? 1;
|
||||
if (count <= 1) socketCounts.delete(webview.id);
|
||||
else socketCounts.set(webview.id, count - 1);
|
||||
};
|
||||
|
||||
const protocols = req.headers['sec-websocket-protocol'];
|
||||
const upstreamSocket = new WsClient(
|
||||
upstreamWebSocketUrl(upstream),
|
||||
protocols ? String(protocols).split(/,\s*/) : [],
|
||||
{
|
||||
headers: {
|
||||
origin: upstream.origin,
|
||||
...(webview.trusted && req.headers.cookie ? { cookie: String(req.headers.cookie) } : {}),
|
||||
},
|
||||
handshakeTimeout: WEBVIEW_UPSTREAM_TIMEOUT_MS,
|
||||
}
|
||||
);
|
||||
|
||||
// Buffer anything the browser sends before the upstream handshake completes,
|
||||
// rather than dropping it: a client that sends a subscribe frame immediately
|
||||
// would otherwise sit connected and silent forever.
|
||||
const pending: Array<Buffer | string> = [];
|
||||
let upstreamOpen = false;
|
||||
|
||||
upstreamSocket.on('open', () => {
|
||||
upstreamOpen = true;
|
||||
for (const message of pending) upstreamSocket.send(message);
|
||||
pending.length = 0;
|
||||
});
|
||||
|
||||
socket.on('message', (data: Buffer, isBinary: boolean) => {
|
||||
const payload = isBinary ? data : data.toString();
|
||||
if (upstreamOpen) upstreamSocket.send(payload);
|
||||
else if (pending.length < 64) pending.push(payload);
|
||||
});
|
||||
|
||||
upstreamSocket.on('message', (data: Buffer, isBinary: boolean) => {
|
||||
if (socket.readyState === socket.OPEN) socket.send(isBinary ? data : data.toString());
|
||||
});
|
||||
|
||||
// Paired close in both directions, so neither side is left half-open.
|
||||
const closeBoth = (code?: number, reason?: string) => {
|
||||
release();
|
||||
// Codes outside 3000-4999 (and 1000/1001) are not valid to send onward.
|
||||
const safeCode = code && code >= 3000 && code <= 4999 ? code : 1000;
|
||||
if (socket.readyState === socket.OPEN) socket.close(safeCode, reason);
|
||||
if (upstreamSocket.readyState === WsClient.OPEN || upstreamSocket.readyState === WsClient.CONNECTING) {
|
||||
upstreamSocket.close(safeCode, reason);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('close', (code: number, reason: Buffer) => closeBoth(code, reason?.toString()));
|
||||
upstreamSocket.on('close', (code: number, reason: Buffer) => closeBoth(code, reason?.toString()));
|
||||
socket.on('error', () => closeBoth());
|
||||
upstreamSocket.on('error', () => {
|
||||
release();
|
||||
if (socket.readyState === socket.OPEN) socket.close(1011, 'Upstream error');
|
||||
});
|
||||
})();
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { z } from 'zod';
|
||||
import { SAFE_PATH_PATTERN, isSafePushEndpoint } from '../utils/index.js';
|
||||
import { isValidWebviewUrl } from './webview-proxy.js';
|
||||
import {
|
||||
MAX_TERMINAL_BUFFER_BYTES,
|
||||
MAX_TERMINAL_SCROLLBACK_LINES,
|
||||
@@ -1184,3 +1185,42 @@ export const SearchQuerySchema = z.object({
|
||||
),
|
||||
limit: z.coerce.number().int().min(1).max(60).optional(),
|
||||
});
|
||||
|
||||
// ========== Web Tabs (dashboard URLs) ==========
|
||||
|
||||
/**
|
||||
* A dashboard URL. `isValidWebviewUrl` rejects anything that is not plain
|
||||
* http/https, anything carrying embedded credentials, and anything without a
|
||||
* hostname. See `src/web/webview-proxy.ts` for why each of those matters.
|
||||
*/
|
||||
const webviewUrlSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'URL is required')
|
||||
.max(2000, 'URL too long (max 2000 chars)')
|
||||
.refine(isValidWebviewUrl, {
|
||||
message: 'Invalid URL: must be http(s), with a hostname and no embedded credentials',
|
||||
});
|
||||
|
||||
const WebviewBaseSchema = z.object({
|
||||
name: z.string().trim().min(1, 'Name is required').max(60, 'Name too long (max 60 chars)'),
|
||||
url: webviewUrlSchema,
|
||||
/** A single glyph shown on the tab. Bounded generously: one emoji can be several code units. */
|
||||
icon: z.string().max(8).optional(),
|
||||
embedMode: z.enum(['proxy', 'direct']).optional(),
|
||||
/**
|
||||
* Opt out of the iframe sandbox. Defaults to false: a proxied page is served
|
||||
* from Codeman's own origin, so `allow-same-origin` would let it read this page
|
||||
* and call the API that spawns agents.
|
||||
*/
|
||||
trusted: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/** POST /api/webviews */
|
||||
export const WebviewCreateSchema = WebviewBaseSchema;
|
||||
|
||||
/** PATCH /api/webviews/:id, partial update. */
|
||||
export const WebviewUpdateSchema = WebviewBaseSchema.partial();
|
||||
|
||||
/** POST /api/webviews/probe: reachability + framing check for the editor's Test button. */
|
||||
export const WebviewProbeSchema = z.object({ url: webviewUrlSchema });
|
||||
|
||||
+11
-4
@@ -160,6 +160,8 @@ import {
|
||||
registerMeRoutes,
|
||||
registerAdminRoutes,
|
||||
registerWsRoutes,
|
||||
registerWebviewRoutes,
|
||||
tryWebviewRefererFallback,
|
||||
} from './routes/index.js';
|
||||
import { CronService } from '../cron/cron-service.js';
|
||||
|
||||
@@ -851,13 +853,17 @@ export class WebServer extends EventEmitter {
|
||||
// Stable-contract 404 for unknown /api routes — without this, Fastify's
|
||||
// default not-found payload {message,error,statusCode} would be wrapped by
|
||||
// the envelope hook into a contradictory HTTP 404 {success:true,...}.
|
||||
this.app.setNotFoundHandler((req, reply) => {
|
||||
this.app.setNotFoundHandler(async (req, reply) => {
|
||||
const notFound = `Route ${req.method}:${req.url} not found`;
|
||||
if (req.url.startsWith('/api')) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, notFound));
|
||||
return;
|
||||
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, notFound));
|
||||
}
|
||||
reply.code(404).send({ message: notFound, error: 'Not Found', statusCode: 404 });
|
||||
// A web-tab dashboard asking for a root-absolute asset (`fetch('/api/data')`,
|
||||
// `import('/chunk.js')`) lands here, because `<base href>` cannot rewrite a URL
|
||||
// built at runtime. Its Referer says which dashboard to relay to. Deliberately
|
||||
// placed on the 404 path so every real Codeman route still wins.
|
||||
if (await tryWebviewRefererFallback(req, reply)) return reply;
|
||||
return reply.code(404).send({ message: notFound, error: 'Not Found', statusCode: 404 });
|
||||
});
|
||||
|
||||
// Crash diagnostics beacon — frontend POSTs breadcrumbs, GET to read them.
|
||||
@@ -925,6 +931,7 @@ export class WebServer extends EventEmitter {
|
||||
registerMeRoutes(this.app, ctx);
|
||||
registerAdminRoutes(this.app, ctx);
|
||||
registerOrchestratorRoutes(this.app, ctx);
|
||||
registerWebviewRoutes(this.app, ctx);
|
||||
|
||||
// Cron: build the service from the same context, recompute
|
||||
// due times for any persisted jobs, then expose it to its routes.
|
||||
|
||||
+10
-1
@@ -5,7 +5,7 @@
|
||||
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
|
||||
* Both files MUST be kept in sync.
|
||||
*
|
||||
* 148 event constants organized by category:
|
||||
* 149 event constants organized by category:
|
||||
* - **Core** (1): init
|
||||
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
|
||||
* - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ...
|
||||
@@ -30,6 +30,7 @@
|
||||
* - **Cases** (4): created, linked, deleted, order-changed
|
||||
* - **Docker cases** (8): exportComplete/Failed, importComplete, imageBuild*, containerRecreated
|
||||
* - **Multi-user** (3): admin:usersChanged, auth:passwordChangeRequired, session:orderChanged
|
||||
* - **Web tabs** (1): webview:changed
|
||||
*
|
||||
* Naming convention: `domain:action` (e.g., `session:created`, `respawn:stateChanged`)
|
||||
*
|
||||
@@ -413,6 +414,11 @@ export const AuthPasswordChangeRequired = 'auth:passwordChangeRequired' as const
|
||||
/** Global session tab order changed (synced across devices). COD-131. */
|
||||
export const SessionOrderChanged = 'session:orderChanged' as const;
|
||||
|
||||
/** A saved web tab (dashboard URL) was created, updated or deleted.
|
||||
* Payload: `{ action: 'created' | 'updated' | 'deleted', id }`. The client
|
||||
* re-fetches the list rather than patching from the payload. */
|
||||
export const WebviewChanged = 'webview:changed' as const;
|
||||
|
||||
// ─── Namespace Re-export ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -615,4 +621,7 @@ export const SseEvent = {
|
||||
|
||||
// Session order (global tab order sync)
|
||||
SessionOrderChanged,
|
||||
|
||||
// Web tabs (dashboard URLs)
|
||||
WebviewChanged,
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
/**
|
||||
* @fileoverview Pure helpers for the web-tab reverse proxy. No I/O, no Fastify.
|
||||
*
|
||||
* The proxy exists because an iframe pointing straight at a dashboard cannot work
|
||||
* in the deployment that matters: prod serves HTTPS (behind `tailscale serve`), so
|
||||
* a plain-HTTP dashboard is hard-blocked as mixed content; many dashboards also
|
||||
* refuse framing outright via `X-Frame-Options` / `frame-ancestors`; and Codeman's
|
||||
* own CSP (`default-src 'self'`) blocks cross-origin frames anyway. Serving the
|
||||
* dashboard through Codeman's own origin dissolves all three at once, and keeps
|
||||
* the production CSP byte-for-byte unchanged because `/webview/...` is `'self'`.
|
||||
*
|
||||
* ## Origin-scoped, not path-scoped
|
||||
*
|
||||
* `/webview/<cap>/x/y` always maps to `<upstream origin>/x/y`, never to
|
||||
* `<upstream origin><saved path>/x/y`. Dashboards reference assets with
|
||||
* root-absolute paths (`/public/build/app.js`), so origin-scoping is the only
|
||||
* mapping under which those resolve. The saved URL's own path+query is used for
|
||||
* exactly one thing: what `/webview/<cap>/` itself serves (the landing page).
|
||||
*
|
||||
* ## What gets rewritten, and why each one is load-bearing
|
||||
*
|
||||
* - `x-frame-options` / CSP `frame-ancestors`: dropped, else the browser refuses
|
||||
* to render the frame. This is the whole point of the proxy.
|
||||
* - `content-encoding` / `content-length`: dropped, because undici's `fetch`
|
||||
* already decoded the body. Forwarding them makes the browser try to gunzip
|
||||
* plaintext.
|
||||
* - `authorization` + the `codeman_session` cookie: stripped on the way OUT. In
|
||||
* trusted mode the iframe is same-origin, so the browser attaches Codeman's own
|
||||
* Basic-auth header and session cookie to every proxied request. Forwarding
|
||||
* those would hand CODEMAN_PASSWORD to the dashboard.
|
||||
* - `Location` and `Set-Cookie`: remapped into the proxy path, else a redirect or
|
||||
* a login cookie escapes the prefix and lands on Codeman's root.
|
||||
* - `<base href>` + root-absolute attribute rewriting: relative and `/`-rooted
|
||||
* URLs in the HTML resolve back through the proxy instead of hitting Codeman.
|
||||
*
|
||||
* No `X-Forwarded-*` is sent deliberately: apps that honor it generate absolute
|
||||
* URLs against Codeman's root, which would bypass the `/webview/<cap>/` prefix
|
||||
* that everything else here works to preserve.
|
||||
*/
|
||||
|
||||
import { WEBVIEW_PROXY_PREFIX } from '../config/webview-limits.js';
|
||||
|
||||
/** Headers that are per-connection and must never be relayed in either direction. */
|
||||
const HOP_BY_HOP = new Set([
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'proxy-authenticate',
|
||||
'proxy-authorization',
|
||||
'proxy-connection',
|
||||
'te',
|
||||
'trailer',
|
||||
'transfer-encoding',
|
||||
'upgrade',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Request headers dropped on the way to the upstream. `authorization` and `cookie`
|
||||
* carry Codeman's own credentials on a same-origin (trusted) frame; `host`,
|
||||
* `content-length` and `accept-encoding` are recomputed by undici.
|
||||
*/
|
||||
const DROP_REQUEST_HEADERS = new Set([
|
||||
...HOP_BY_HOP,
|
||||
'host',
|
||||
'content-length',
|
||||
'accept-encoding',
|
||||
'authorization',
|
||||
'cookie',
|
||||
'origin',
|
||||
'referer',
|
||||
'x-codeman-hook-secret',
|
||||
]);
|
||||
|
||||
/** Response headers dropped on the way back to the browser. */
|
||||
const DROP_RESPONSE_HEADERS = new Set([
|
||||
...HOP_BY_HOP,
|
||||
'content-encoding',
|
||||
'content-length',
|
||||
'x-frame-options',
|
||||
'content-security-policy-report-only',
|
||||
// Cross-origin isolation headers describe the UPSTREAM's origin policy; applied
|
||||
// to a frame on Codeman's origin they only produce blocked-resource surprises.
|
||||
'cross-origin-opener-policy',
|
||||
'cross-origin-embedder-policy',
|
||||
'cross-origin-resource-policy',
|
||||
'set-cookie',
|
||||
'location',
|
||||
'content-security-policy',
|
||||
// The upstream's CORS answer describes ITS origin; the frame asking is
|
||||
// opaque-origin on ours, so ours must replace it (see buildProxyCorsHeaders).
|
||||
'access-control-allow-origin',
|
||||
'access-control-allow-credentials',
|
||||
'access-control-allow-methods',
|
||||
'access-control-allow-headers',
|
||||
'access-control-expose-headers',
|
||||
'access-control-max-age',
|
||||
]);
|
||||
|
||||
/** The same-origin path prefix an iframe loads for a given capability. */
|
||||
export function proxyPrefixFor(capability: string): string {
|
||||
return `${WEBVIEW_PROXY_PREFIX}/${capability}/`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate a user-supplied dashboard URL.
|
||||
*
|
||||
* Rejects everything that is not plain `http:`/`https:`, anything carrying
|
||||
* embedded credentials (they would be silently forwarded and logged), and
|
||||
* anything without a hostname. Returns the normalized `URL` or null.
|
||||
*/
|
||||
export function parseWebviewUrl(raw: string): URL | null {
|
||||
if (typeof raw !== 'string' || raw.trim() === '') return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||
if (url.username !== '' || url.password !== '') return null;
|
||||
if (!url.hostname) return null;
|
||||
return url;
|
||||
}
|
||||
|
||||
/** Convenience predicate for Zod refinements. */
|
||||
export function isValidWebviewUrl(raw: string): boolean {
|
||||
return parseWebviewUrl(raw) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a proxy request path to its upstream URL.
|
||||
*
|
||||
* `wildcard` is Fastify's `*` param: the path after `/webview/<cap>/`, without a
|
||||
* leading slash. An empty wildcard means the landing page, which is the saved
|
||||
* URL's own path and query.
|
||||
*
|
||||
* Returns null when the result would escape the upstream origin (a `..` chain, a
|
||||
* protocol-relative `//evil.com` wildcard, or an absolute URL smuggled into the
|
||||
* path). That check is what keeps this from being an open proxy.
|
||||
*/
|
||||
export function resolveUpstreamUrl(savedUrl: string, wildcard: string, search: string): URL | null {
|
||||
const base = parseWebviewUrl(savedUrl);
|
||||
if (!base) return null;
|
||||
|
||||
if (wildcard === '' || wildcard === '/') {
|
||||
const landing = new URL(base.pathname + (search || base.search), base.origin);
|
||||
return landing.origin === base.origin ? landing : null;
|
||||
}
|
||||
|
||||
// A wildcard starting with `//` would parse as protocol-relative and jump host.
|
||||
const path = wildcard.startsWith('/') ? wildcard : `/${wildcard}`;
|
||||
if (path.startsWith('//')) return null;
|
||||
|
||||
let target: URL;
|
||||
try {
|
||||
target = new URL(path + (search || ''), base.origin);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return target.origin === base.origin ? target : null;
|
||||
}
|
||||
|
||||
/** Extract the capability from a `/webview/<cap>/...` pathname, or null. */
|
||||
export function capabilityFromProxyPath(pathname: string): string | null {
|
||||
if (typeof pathname !== 'string') return null;
|
||||
const prefix = `${WEBVIEW_PROXY_PREFIX}/`;
|
||||
if (!pathname.startsWith(prefix)) return null;
|
||||
const rest = pathname.slice(prefix.length);
|
||||
const slash = rest.indexOf('/');
|
||||
const cap = slash === -1 ? rest : rest.slice(0, slash);
|
||||
return /^[A-Za-z0-9_-]{16,128}$/.test(cap) ? cap : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the capability a `Referer` belongs to. Backs the 404 fallback that
|
||||
* catches root-absolute asset requests (`/static/app.js`) which `<base>` cannot fix.
|
||||
*/
|
||||
export function capabilityFromReferer(referer: string | undefined): string | null {
|
||||
if (!referer) return null;
|
||||
try {
|
||||
return capabilityFromProxyPath(new URL(referer).pathname);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the `frame-ancestors` directive from a CSP, preserving the rest. */
|
||||
export function stripFrameAncestors(csp: string): string {
|
||||
return csp
|
||||
.split(';')
|
||||
.map((d) => d.trim())
|
||||
.filter((d) => d !== '' && !/^frame-ancestors\b/i.test(d))
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
/** The `frame-ancestors` directive value from a CSP, or undefined. */
|
||||
export function extractFrameAncestors(csp: string | undefined): string | undefined {
|
||||
if (!csp) return undefined;
|
||||
for (const directive of csp.split(';')) {
|
||||
const trimmed = directive.trim();
|
||||
if (/^frame-ancestors\b/i.test(trimmed)) {
|
||||
return trimmed.slice('frame-ancestors'.length).trim();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a target permits being framed by a different origin, judged from its
|
||||
* `X-Frame-Options` and CSP. Used only to recommend proxy vs direct mode in the
|
||||
* editor; the proxy path works either way.
|
||||
*/
|
||||
export function isFramableCrossOrigin(xFrameOptions: string | undefined, csp: string | undefined): boolean {
|
||||
const xfo = xFrameOptions?.trim().toLowerCase();
|
||||
if (xfo === 'deny' || xfo === 'sameorigin') return false;
|
||||
const ancestors = extractFrameAncestors(csp)?.toLowerCase();
|
||||
if (ancestors === undefined) return true;
|
||||
if (ancestors.includes("'none'")) return false;
|
||||
// 'self' alone means same-origin only, which a cross-origin embed is not.
|
||||
if (ancestors === "'self'") return false;
|
||||
return ancestors.includes('*') || ancestors.includes('http');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite an upstream `Location` into the proxy path.
|
||||
*
|
||||
* Same-origin redirects (relative or absolute) are remapped so the browser stays
|
||||
* inside the frame. Cross-origin redirects are returned unchanged rather than
|
||||
* proxied: relaying them would turn this into an open proxy for any host the
|
||||
* upstream chooses to name.
|
||||
*/
|
||||
export function rewriteLocation(location: string, requestUrl: URL, capability: string): string {
|
||||
let resolved: URL;
|
||||
try {
|
||||
resolved = new URL(location, requestUrl);
|
||||
} catch {
|
||||
return location;
|
||||
}
|
||||
if (resolved.origin !== requestUrl.origin) return location;
|
||||
const suffix = resolved.pathname.replace(/^\//, '');
|
||||
return `${proxyPrefixFor(capability)}${suffix}${resolved.search}${resolved.hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite an upstream `Set-Cookie` so it applies to the proxy path only.
|
||||
*
|
||||
* `Domain` is dropped (the cookie now belongs to Codeman's host), `Path` is
|
||||
* rebased onto the proxy prefix so two dashboards cannot collide on a shared
|
||||
* cookie name, and `Secure` is dropped when Codeman itself is serving plain HTTP
|
||||
* in dev, where a Secure cookie would simply be discarded.
|
||||
*/
|
||||
export function rewriteSetCookie(cookie: string, capability: string, secureContext: boolean): string {
|
||||
const parts = cookie.split(';');
|
||||
const out: string[] = [parts[0]];
|
||||
let sawPath = false;
|
||||
|
||||
for (const raw of parts.slice(1)) {
|
||||
const attr = raw.trim();
|
||||
const lower = attr.toLowerCase();
|
||||
if (lower.startsWith('domain=')) continue;
|
||||
if (lower === 'secure' && !secureContext) continue;
|
||||
if (lower.startsWith('path=')) {
|
||||
sawPath = true;
|
||||
const value = attr.slice('path='.length);
|
||||
const suffix = value.replace(/^\//, '');
|
||||
out.push(`Path=${proxyPrefixFor(capability)}${suffix}`);
|
||||
continue;
|
||||
}
|
||||
out.push(attr);
|
||||
}
|
||||
|
||||
if (!sawPath) out.push(`Path=${proxyPrefixFor(capability)}`);
|
||||
return out.join('; ');
|
||||
}
|
||||
|
||||
/** Drop named cookies from a `Cookie` request header, returning undefined if none remain. */
|
||||
export function filterCookieHeader(cookie: string | undefined, drop: string[]): string | undefined {
|
||||
if (!cookie) return undefined;
|
||||
const dropSet = new Set(drop.map((n) => n.toLowerCase()));
|
||||
const kept = cookie
|
||||
.split(';')
|
||||
.map((c) => c.trim())
|
||||
.filter((c) => c !== '' && !dropSet.has(c.slice(0, c.indexOf('=')).trim().toLowerCase()));
|
||||
return kept.length > 0 ? kept.join('; ') : undefined;
|
||||
}
|
||||
|
||||
/** Build the header set sent upstream, from the browser's request headers. */
|
||||
export function buildUpstreamRequestHeaders(
|
||||
incoming: Record<string, string | string[] | undefined>,
|
||||
upstream: URL,
|
||||
opts: { forwardCookies: boolean; sessionCookieName: string; refererPath?: string }
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(incoming)) {
|
||||
const lower = key.toLowerCase();
|
||||
if (DROP_REQUEST_HEADERS.has(lower)) continue;
|
||||
if (value === undefined) continue;
|
||||
headers[lower] = Array.isArray(value) ? value.join(', ') : value;
|
||||
}
|
||||
|
||||
if (opts.forwardCookies) {
|
||||
const raw = incoming['cookie'];
|
||||
const cookie = filterCookieHeader(Array.isArray(raw) ? raw.join('; ') : raw, [opts.sessionCookieName]);
|
||||
if (cookie) headers['cookie'] = cookie;
|
||||
}
|
||||
|
||||
// Present as if the browser were talking to the dashboard directly. Apps that
|
||||
// check Origin on writes (CSRF defenses) need this to match their own origin.
|
||||
headers['origin'] = upstream.origin;
|
||||
headers['referer'] = opts.refererPath ? new URL(opts.refererPath, upstream.origin).href : upstream.href;
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the response headers sent to the browser.
|
||||
*
|
||||
* Also returns the CSP to apply: the upstream's, minus `frame-ancestors`. Callers
|
||||
* MUST set (or explicitly clear) this, because `registerSecurityHeaders` has
|
||||
* already stamped Codeman's own `default-src 'self'` policy onto the reply, and
|
||||
* that policy would break virtually every dashboard.
|
||||
*/
|
||||
export function buildDownstreamResponseHeaders(
|
||||
upstreamHeaders: Iterable<[string, string]>,
|
||||
/**
|
||||
* Upstream `Set-Cookie` values, already separated. Passed in rather than read
|
||||
* from `upstreamHeaders` because iterating a `Headers` object JOINS duplicate
|
||||
* set-cookie values into one comma-separated string, which cannot be split back
|
||||
* apart reliably (Expires dates contain commas). Callers use
|
||||
* `response.headers.getSetCookie()`.
|
||||
*/
|
||||
setCookies: string[],
|
||||
capability: string,
|
||||
requestUrl: URL,
|
||||
secureContext: boolean
|
||||
): { headers: Record<string, string>; setCookie: string[]; csp: string | null } {
|
||||
const headers: Record<string, string> = {};
|
||||
let csp: string | null = null;
|
||||
|
||||
for (const [key, value] of upstreamHeaders) {
|
||||
const lower = key.toLowerCase();
|
||||
if (lower === 'content-security-policy') {
|
||||
const stripped = stripFrameAncestors(value);
|
||||
csp = stripped === '' ? null : stripped;
|
||||
continue;
|
||||
}
|
||||
if (lower === 'location') {
|
||||
headers['location'] = rewriteLocation(value, requestUrl, capability);
|
||||
continue;
|
||||
}
|
||||
if (DROP_RESPONSE_HEADERS.has(lower)) continue;
|
||||
headers[lower] = value;
|
||||
}
|
||||
|
||||
const setCookie = setCookies.map((cookie) => rewriteSetCookie(cookie, capability, secureContext));
|
||||
|
||||
return { headers, setCookie, csp };
|
||||
}
|
||||
|
||||
/**
|
||||
* A tiny script injected at the top of every proxied document, rewriting
|
||||
* ROOT-ABSOLUTE URLs built at runtime so they stay inside the proxy prefix.
|
||||
*
|
||||
* `<base href>` only governs URLs the HTML parser resolves. A dashboard that calls
|
||||
* `fetch('/api/data')` bypasses it entirely and the request lands on Codeman's own
|
||||
* root, where it 404s. That is not a rare shape: it is how most dashboards talk to
|
||||
* their own backend, and it presents as the dashboard's own "Failed to fetch".
|
||||
*
|
||||
* The `Referer`-keyed 404 fallback catches some of these, but deliberately NOT
|
||||
* paths under `/api`, `/ws` or `/q` (widening it there would let a request-supplied
|
||||
* header skip auth on Codeman's own API). Rewriting inside the iframe removes the
|
||||
* whole class instead of trading security for it: the page never emits a
|
||||
* root-absolute request in the first place.
|
||||
*
|
||||
* Runs before any page script because it is injected immediately after `<base>`.
|
||||
* Only same-origin, non-prefixed, root-absolute URLs are touched; relative URLs
|
||||
* (already handled by `<base>`) and cross-origin URLs are passed through.
|
||||
*/
|
||||
export function runtimeUrlShim(prefix: string): string {
|
||||
// Kept dependency-free and defensive: it runs inside a page we do not control,
|
||||
// and a throw here would break the dashboard rather than fix it.
|
||||
return `<script>(function(){try{
|
||||
var P=${JSON.stringify(prefix)};
|
||||
function rw(u){
|
||||
try{
|
||||
if(u==null)return u;
|
||||
if(typeof u!=='string'){
|
||||
if(typeof URL!=='undefined'&&u instanceof URL)return rw(u.href);
|
||||
return u;
|
||||
}
|
||||
if(u.indexOf(P)===0)return u;
|
||||
if(u.charAt(0)==='/'&&u.charAt(1)!=='/')return P+u.slice(1);
|
||||
if(/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(u)||u.indexOf('//')===0){
|
||||
var a=new URL(u,location.href);
|
||||
if(a.host===location.host&&a.pathname.indexOf(P)!==0){
|
||||
a.pathname=P+a.pathname.replace(/^\\//,'');
|
||||
return a.href;
|
||||
}
|
||||
}
|
||||
return u;
|
||||
}catch(e){return u;}
|
||||
}
|
||||
var of=window.fetch;
|
||||
if(of)window.fetch=function(i,o){
|
||||
try{
|
||||
if(typeof Request!=='undefined'&&i instanceof Request)return of.call(this,new Request(rw(i.url),i),o);
|
||||
return of.call(this,rw(i),o);
|
||||
}catch(e){return of.call(this,i,o);}
|
||||
};
|
||||
if(window.XMLHttpRequest&&XMLHttpRequest.prototype.open){
|
||||
var oo=XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open=function(m,u){
|
||||
var a=[].slice.call(arguments);a[1]=rw(u);return oo.apply(this,a);
|
||||
};
|
||||
}
|
||||
['WebSocket','EventSource'].forEach(function(k){
|
||||
var C=window[k];if(!C)return;
|
||||
function W(u,p){return p===undefined?new C(rw(u)):new C(rw(u),p);}
|
||||
W.prototype=C.prototype;
|
||||
['CONNECTING','OPEN','CLOSING','CLOSED'].forEach(function(s){if(s in C)W[s]=C[s];});
|
||||
window[k]=W;
|
||||
});
|
||||
}catch(e){}})();</script>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject `<base href="/webview/<cap>/">` plus the runtime URL shim, and rebase
|
||||
* root-absolute `src`/`href`/`action` attributes, which `<base>` alone does not
|
||||
* affect.
|
||||
*
|
||||
* Three layers, because no single one is sufficient: `<base>` for parser-resolved
|
||||
* relative URLs, attribute rewriting for root-absolute markup, and the shim for
|
||||
* URLs built at runtime.
|
||||
*/
|
||||
export function rewriteHtml(html: string, capability: string): string {
|
||||
const prefix = proxyPrefixFor(capability);
|
||||
|
||||
// Fresh regexes per call: module-level /g patterns carry `lastIndex` between calls.
|
||||
const rebased = html
|
||||
.replace(/(\s(?:src|href|action)\s*=\s*")\/(?!\/)/gi, `$1${prefix}`)
|
||||
.replace(/(\s(?:src|href|action)\s*=\s*')\/(?!\/)/gi, `$1${prefix}`);
|
||||
|
||||
// A page that ships its own <base> keeps it (overriding it would break the
|
||||
// author's intent), but it STILL needs the shim, which is the layer that
|
||||
// catches runtime-built URLs. So only the base tag is conditional.
|
||||
const injected = (/<base\b/i.test(rebased) ? '' : `<base href="${prefix}">`) + runtimeUrlShim(prefix);
|
||||
|
||||
const headMatch = /<head\b[^>]*>/i.exec(rebased);
|
||||
if (headMatch) {
|
||||
const at = headMatch.index + headMatch[0].length;
|
||||
return rebased.slice(0, at) + injected + rebased.slice(at);
|
||||
}
|
||||
const htmlMatch = /<html\b[^>]*>/i.exec(rebased);
|
||||
if (htmlMatch) {
|
||||
const at = htmlMatch.index + htmlMatch[0].length;
|
||||
return rebased.slice(0, at) + injected + rebased.slice(at);
|
||||
}
|
||||
return injected + rebased;
|
||||
}
|
||||
|
||||
/**
|
||||
* CORS headers for a proxied response.
|
||||
*
|
||||
* Non-obvious but load-bearing: a SANDBOXED iframe (no `allow-same-origin`) runs
|
||||
* in an OPAQUE origin, so every `fetch`/XHR it makes is a cross-origin request even
|
||||
* though the URL is on this very host, and the browser requires CORS headers to
|
||||
* hand back the response. Without this, a dashboard renders fine (script/css/img
|
||||
* loads are not CORS-checked) while every one of its API calls fails with an opaque
|
||||
* `net::ERR_FAILED` and the page shows its own "failed to load" state. `curl`
|
||||
* cannot reproduce it, because curl does not enforce CORS.
|
||||
*
|
||||
* The origin is echoed rather than `*` so credentialed requests still work in
|
||||
* trusted mode. `null` (the opaque-origin case) is echoed as-is, but WITHOUT
|
||||
* `allow-credentials`, which browsers reject in combination.
|
||||
*
|
||||
* This grants nothing extra: the URL is already gated by the capability, and only
|
||||
* a document that was handed the capability can construct these requests.
|
||||
*/
|
||||
export function buildProxyCorsHeaders(origin: string | undefined, requestedHeaders?: string): Record<string, string> {
|
||||
if (!origin) return {};
|
||||
const headers: Record<string, string> = {
|
||||
'access-control-allow-origin': origin,
|
||||
'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS',
|
||||
'access-control-allow-headers': requestedHeaders && requestedHeaders.trim() !== '' ? requestedHeaders : '*',
|
||||
'access-control-expose-headers': '*',
|
||||
'access-control-max-age': '600',
|
||||
vary: 'Origin',
|
||||
};
|
||||
// `Access-Control-Allow-Credentials: true` alongside a `null` origin is rejected
|
||||
// by browsers; a sandboxed frame sends no credentials anyway.
|
||||
if (origin !== 'null' && origin !== '*') headers['access-control-allow-credentials'] = 'true';
|
||||
return headers;
|
||||
}
|
||||
|
||||
/** Whether a content-type identifies HTML worth rewriting. */
|
||||
export function isHtmlContentType(contentType: string | undefined): boolean {
|
||||
if (!contentType) return false;
|
||||
const type = contentType.split(';')[0].trim().toLowerCase();
|
||||
return type === 'text/html' || type === 'application/xhtml+xml';
|
||||
}
|
||||
|
||||
/** Map an upstream http(s) URL to its ws(s) equivalent for the WebSocket leg. */
|
||||
export function upstreamWebSocketUrl(target: URL): string {
|
||||
const ws = new URL(target.href);
|
||||
ws.protocol = ws.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return ws.href;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @fileoverview Capability tokens for the web-tab proxy.
|
||||
*
|
||||
* The proxy cannot authenticate on Codeman's session cookie. A sandboxed iframe
|
||||
* (no `allow-same-origin`) runs in an OPAQUE origin, so every request it makes is
|
||||
* cross-site: the `SameSite=lax` `codeman_session` cookie is not sent, and its
|
||||
* non-GET requests and WebSocket upgrades arrive with `Origin: null`, which the
|
||||
* host guard rejects by design.
|
||||
*
|
||||
* So `/webview/:cap/*` authenticates on an unguessable capability minted by an
|
||||
* already-authenticated `POST /api/webviews/:id/open`. Properties that make this
|
||||
* safe to exempt from the cookie/Origin checks:
|
||||
*
|
||||
* - 128 bits of `randomBytes` entropy, base64url, never derived from anything.
|
||||
* - Held in memory only. A restart invalidates every outstanding capability.
|
||||
* - Rolling TTL: refreshed on use, expired after inactivity.
|
||||
* - Bound to the minting user, so multi-user ownership survives the exemption.
|
||||
* - Grants exactly one thing: relaying bytes to that one saved URL. It reaches no
|
||||
* session, no file, no API surface.
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { StaleExpirationMap } from './utils/index.js';
|
||||
import { MAX_WEBVIEW_CAPABILITIES, WEBVIEW_CAPABILITY_TTL_MS } from './config/webview-limits.js';
|
||||
|
||||
export interface WebviewCapabilityRecord {
|
||||
webviewId: string;
|
||||
/** Username that minted it (multi-user); undefined in single-user mode. */
|
||||
owner?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export class WebviewCapabilityStore {
|
||||
private readonly capabilities: StaleExpirationMap<string, WebviewCapabilityRecord>;
|
||||
/** Reverse index so re-opening a webview reuses its capability instead of leaking one per click. */
|
||||
private readonly byWebview = new Map<string, string>();
|
||||
|
||||
constructor(ttlMs: number = WEBVIEW_CAPABILITY_TTL_MS) {
|
||||
this.capabilities = new StaleExpirationMap<string, WebviewCapabilityRecord>({
|
||||
ttlMs,
|
||||
refreshOnGet: true,
|
||||
onExpire: (_token, record) => {
|
||||
const current = this.byWebview.get(record.webviewId);
|
||||
if (current !== undefined) this.byWebview.delete(record.webviewId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Mint (or reuse) a capability for a webview. Returns the token. */
|
||||
mint(webviewId: string, owner?: string): string {
|
||||
const existing = this.byWebview.get(webviewId);
|
||||
if (existing) {
|
||||
const record = this.capabilities.get(existing);
|
||||
// Reuse only while the record is live AND still belongs to the same identity.
|
||||
if (record && record.owner === owner) return existing;
|
||||
this.capabilities.delete(existing);
|
||||
this.byWebview.delete(webviewId);
|
||||
}
|
||||
|
||||
// Bound growth: a client that never reuses tokens must not grow this forever.
|
||||
if (this.capabilities.size >= MAX_WEBVIEW_CAPABILITIES) this.capabilities.cleanup();
|
||||
|
||||
const token = randomBytes(24).toString('base64url');
|
||||
this.capabilities.set(token, { webviewId, owner, createdAt: Date.now() });
|
||||
this.byWebview.set(webviewId, token);
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Resolve a capability, refreshing its TTL. Returns undefined when unknown or expired. */
|
||||
resolve(token: string): WebviewCapabilityRecord | undefined {
|
||||
if (!token) return undefined;
|
||||
return this.capabilities.get(token);
|
||||
}
|
||||
|
||||
/** Revoke every capability for a webview (called on delete/edit). */
|
||||
revokeWebview(webviewId: string): void {
|
||||
const token = this.byWebview.get(webviewId);
|
||||
if (token) {
|
||||
this.capabilities.delete(token);
|
||||
this.byWebview.delete(webviewId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Revoke every capability minted by a user (called on logout / user deletion). */
|
||||
revokeOwner(owner: string): void {
|
||||
for (const [webviewId, token] of [...this.byWebview]) {
|
||||
const record = this.capabilities.peek(token);
|
||||
if (record?.owner === owner) {
|
||||
this.capabilities.delete(token);
|
||||
this.byWebview.delete(webviewId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.capabilities.size;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.capabilities.dispose();
|
||||
this.byWebview.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process-wide capability store.
|
||||
*
|
||||
* A singleton rather than an injected dependency because two unrelated layers must
|
||||
* agree on it: the proxy routes that mint and consume capabilities, and the auth
|
||||
* middleware, which has to recognize a valid capability to know that a
|
||||
* `/webview/...` request is legitimately exempt from the cookie and Origin checks.
|
||||
* Threading a store through the auth middleware's construction just to answer that
|
||||
* one question would be worse. The map's cleanup timer is `unref`'d, so holding
|
||||
* this at module scope does not keep the process alive.
|
||||
*/
|
||||
export const webviewCapabilities = new WebviewCapabilityStore();
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @fileoverview Persistence for web tabs (saved dashboard URLs).
|
||||
*
|
||||
* Stores `Webview` records in `~/.codeman/webviews.json`, following the same
|
||||
* read-array / write-array shape as `src/remote-hosts.ts`. Deliberately dumb: no
|
||||
* caching, no watchers. The list is small (bounded by MAX_WEBVIEWS) and is read
|
||||
* on demand by the route handlers.
|
||||
*
|
||||
* The file lives under the instance data dir, so a beta instance started with a
|
||||
* distinct CODEMAN_INSTANCE keeps its own dashboards.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { Webview } from './types.js';
|
||||
|
||||
const WEBVIEWS_FILE = 'webviews.json';
|
||||
|
||||
export function webviewsPath(configDir: string): string {
|
||||
return join(configDir, WEBVIEWS_FILE);
|
||||
}
|
||||
|
||||
export async function readWebviews(configDir: string): Promise<Webview[]> {
|
||||
try {
|
||||
const raw = await fs.readFile(webviewsPath(configDir), 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as Webview[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeWebviews(configDir: string, webviews: Webview[]): Promise<void> {
|
||||
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
|
||||
await fs.writeFile(webviewsPath(configDir), JSON.stringify(webviews, null, 2));
|
||||
}
|
||||
@@ -79,6 +79,61 @@ describe('terminal link-provider regexes (shipped source)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('urlPattern keeps query strings whole (a single & is part of the URL)', () => {
|
||||
// Excluding `&` truncated every real query string: a WordPress edit link
|
||||
// resolved to `?post=1479` and opened the wrong page, and Claude Code's OAuth
|
||||
// login URL (many `&` params) was not clickable at all.
|
||||
const url = shippedPattern('urlPattern');
|
||||
const strip = (u: string) => u.replace(/[.,;:!?)&]+$/, '');
|
||||
const cases: Array<[string, string]> = [
|
||||
[
|
||||
'updated in place: https://bio-hacking.blog/wp-admin/post.php?post=1479&action=edit',
|
||||
'https://bio-hacking.blog/wp-admin/post.php?post=1479&action=edit',
|
||||
],
|
||||
[
|
||||
'open https://claude.ai/oauth/authorize?code=true&client_id=abc&scope=user%3Ainference&state=xyz',
|
||||
'https://claude.ai/oauth/authorize?code=true&client_id=abc&scope=user%3Ainference&state=xyz',
|
||||
],
|
||||
['see https://x.com/a?b=1&c=2&d=3 ok', 'https://x.com/a?b=1&c=2&d=3'],
|
||||
// A lone trailing & is punctuation, not part of the target.
|
||||
['trailing https://x.com/a?b=1& next', 'https://x.com/a?b=1'],
|
||||
];
|
||||
for (const [line, want] of cases) {
|
||||
url.lastIndex = 0;
|
||||
const m = url.exec(line);
|
||||
expect(m, line).not.toBeNull();
|
||||
expect(strip(m![0]), line).toBe(want);
|
||||
}
|
||||
});
|
||||
|
||||
it('urlPattern still stops at the shell && operator', () => {
|
||||
// `&&` never appears inside a URL, so it must remain a boundary or a link
|
||||
// would swallow the next command.
|
||||
const url = shippedPattern('urlPattern');
|
||||
for (const line of ['curl https://x.com/api && echo done', 'curl https://x.com/api&&echo done']) {
|
||||
url.lastIndex = 0;
|
||||
expect(url.exec(line)![0], line).toBe('https://x.com/api');
|
||||
}
|
||||
});
|
||||
|
||||
it('extPattern links pasted image/PDF attachment paths', () => {
|
||||
// `.claude-images/paste-*.png` is what Codeman writes for a pasted screenshot;
|
||||
// without image extensions the path rendered as plain, unclickable text.
|
||||
const ext = shippedPattern('extPattern');
|
||||
const cases = [
|
||||
'/home/arkon/default/claudeman/.claude-images/paste-1785164958410-d11eb7d0.png',
|
||||
'/tmp/shot.jpeg',
|
||||
'/opt/app/report.pdf',
|
||||
'/home/a/diagram.svg',
|
||||
];
|
||||
for (const path of cases) {
|
||||
ext.lastIndex = 0;
|
||||
const m = ext.exec(`see ${path} here`);
|
||||
expect(m, path).not.toBeNull();
|
||||
expect(m![1], path).toBe(path);
|
||||
}
|
||||
});
|
||||
|
||||
it('cmdPattern arg group cannot match empty tokens (the exponential trigger)', () => {
|
||||
// structural guard: the dangerous construct is an empty-matchable token
|
||||
// inside a repeated group — `[^\s\/]*\s+` repeated. Check the pattern
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* CRUD + capability behaviour for /api/webviews.
|
||||
*
|
||||
* Uses app.inject() (no port) against a temp CODEMAN_DATA_DIR, so nothing touches
|
||||
* the developer's real ~/.codeman/webviews.json.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyWebsocket from '@fastify/websocket';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { registerWebviewRoutes } from '../../src/web/routes/webview-routes.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { webviewCapabilities } from '../../src/webview-capabilities.js';
|
||||
import { capabilityFromProxyPath } from '../../src/web/webview-proxy.js';
|
||||
|
||||
let app: FastifyInstance;
|
||||
let tmpDir: string;
|
||||
let savedDataDir: string | undefined;
|
||||
const broadcasts: Array<{ event: string; data: unknown }> = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-webviews-'));
|
||||
savedDataDir = process.env.CODEMAN_DATA_DIR;
|
||||
process.env.CODEMAN_DATA_DIR = tmpDir;
|
||||
broadcasts.length = 0;
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
// The proxy route declares a wsHandler, so the plugin must be present.
|
||||
await app.register(fastifyWebsocket);
|
||||
registerWebviewRoutes(app, {
|
||||
broadcast: (event: string, data: unknown) => broadcasts.push({ event, data }),
|
||||
} as never);
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
if (savedDataDir === undefined) delete process.env.CODEMAN_DATA_DIR;
|
||||
else process.env.CODEMAN_DATA_DIR = savedDataDir;
|
||||
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
});
|
||||
|
||||
const create = (payload: Record<string, unknown>) => app.inject({ method: 'POST', url: '/api/webviews', payload });
|
||||
|
||||
describe('GET /api/webviews', () => {
|
||||
it('starts empty and reports the frame budget the client must honour', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/webviews' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.webviews).toEqual([]);
|
||||
expect(typeof body.data.maxLiveFrames).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/webviews', () => {
|
||||
it('creates a dashboard that defaults to proxied and sandboxed', async () => {
|
||||
const res = await create({ name: 'Grafana', url: 'http://127.0.0.1:4000/' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const w = res.json().data;
|
||||
// Proxy + untrusted are the safe defaults and must not drift.
|
||||
expect(w.embedMode).toBe('proxy');
|
||||
expect(w.trusted).toBe(false);
|
||||
expect(w.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it('broadcasts the change so other devices re-fetch', async () => {
|
||||
await create({ name: 'G', url: 'http://127.0.0.1:4000/' });
|
||||
expect(broadcasts.map((b) => b.event)).toContain('webview:changed');
|
||||
});
|
||||
|
||||
it('persists across a fresh read of the store', async () => {
|
||||
await create({ name: 'G', url: 'http://127.0.0.1:4000/' });
|
||||
const list = (await app.inject({ method: 'GET', url: '/api/webviews' })).json().data.webviews;
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].name).toBe('G');
|
||||
});
|
||||
|
||||
it('rejects URLs that are not plain http(s)', async () => {
|
||||
for (const url of ['javascript:alert(1)', 'file:///etc/passwd', 'data:text/html,x']) {
|
||||
const res = await create({ name: 'bad', url });
|
||||
expect(res.statusCode, url).toBe(400);
|
||||
expect(res.json().errorCode).toBe('INVALID_INPUT');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects URLs carrying embedded credentials', async () => {
|
||||
const res = await create({ name: 'bad', url: 'http://user:pass@host:4000/' });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('requires a name', async () => {
|
||||
expect((await create({ url: 'http://127.0.0.1:4000/' })).statusCode).toBe(400);
|
||||
expect((await create({ name: ' ', url: 'http://127.0.0.1:4000/' })).statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/webviews/:id', () => {
|
||||
it('updates fields and revokes the outstanding capability', async () => {
|
||||
const id = (await create({ name: 'G', url: 'http://127.0.0.1:4000/' })).json().data.id;
|
||||
const opened = await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` });
|
||||
const cap = capabilityFromProxyPath(opened.json().data.embedUrl)!;
|
||||
expect(webviewCapabilities.resolve(cap)).toBeDefined();
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/webviews/${id}`,
|
||||
payload: { url: 'http://127.0.0.1:4001/' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data.url).toBe('http://127.0.0.1:4001/');
|
||||
// A token minted against the OLD url must not survive the repoint.
|
||||
expect(webviewCapabilities.resolve(cap)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('404s an unknown id', async () => {
|
||||
const res = await app.inject({ method: 'PATCH', url: '/api/webviews/nope', payload: { name: 'x' } });
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('still validates the URL on update', async () => {
|
||||
const id = (await create({ name: 'G', url: 'http://127.0.0.1:4000/' })).json().data.id;
|
||||
const res = await app.inject({ method: 'PATCH', url: `/api/webviews/${id}`, payload: { url: 'file:///etc' } });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/webviews/:id', () => {
|
||||
it('removes it and revokes its capability', async () => {
|
||||
const id = (await create({ name: 'G', url: 'http://127.0.0.1:4000/' })).json().data.id;
|
||||
const opened = await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` });
|
||||
const cap = capabilityFromProxyPath(opened.json().data.embedUrl)!;
|
||||
|
||||
expect((await app.inject({ method: 'DELETE', url: `/api/webviews/${id}` })).statusCode).toBe(200);
|
||||
expect((await app.inject({ method: 'GET', url: '/api/webviews' })).json().data.webviews).toEqual([]);
|
||||
expect(webviewCapabilities.resolve(cap)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('404s an unknown id', async () => {
|
||||
expect((await app.inject({ method: 'DELETE', url: '/api/webviews/nope' })).statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/webviews/:id/open', () => {
|
||||
it('mints a same-origin embed path for a proxied dashboard', async () => {
|
||||
const id = (await create({ name: 'G', url: 'http://127.0.0.1:4000/' })).json().data.id;
|
||||
const data = (await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` })).json().data;
|
||||
expect(data.embedUrl).toMatch(/^\/webview\/[A-Za-z0-9_-]{16,}\/$/);
|
||||
expect(capabilityFromProxyPath(data.embedUrl)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns no embed path in direct mode, where the iframe uses the real URL', async () => {
|
||||
const id = (await create({ name: 'G', url: 'https://ok.example/', embedMode: 'direct' })).json().data.id;
|
||||
const data = (await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` })).json().data;
|
||||
expect(data.embedUrl).toBeUndefined();
|
||||
expect(data.webview.url).toBe('https://ok.example/');
|
||||
});
|
||||
|
||||
it('reuses the capability across repeated opens instead of leaking one per click', async () => {
|
||||
const id = (await create({ name: 'G', url: 'http://127.0.0.1:4000/' })).json().data.id;
|
||||
const first = (await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` })).json().data.embedUrl;
|
||||
const second = (await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` })).json().data.embedUrl;
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it('records lastOpenedAt', async () => {
|
||||
const id = (await create({ name: 'G', url: 'http://127.0.0.1:4000/' })).json().data.id;
|
||||
await app.inject({ method: 'POST', url: `/api/webviews/${id}/open` });
|
||||
const list = (await app.inject({ method: 'GET', url: '/api/webviews' })).json().data.webviews;
|
||||
expect(typeof list[0].lastOpenedAt).toBe('number');
|
||||
});
|
||||
|
||||
it('404s an unknown id', async () => {
|
||||
expect((await app.inject({ method: 'POST', url: '/api/webviews/nope/open' })).statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('proxy route', () => {
|
||||
it('refuses an unknown or expired capability', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: `/webview/${'Z'.repeat(32)}/` });
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('redirects the prefix without a trailing slash, so relative URLs resolve inside it', async () => {
|
||||
const cap = 'Y'.repeat(32);
|
||||
const res = await app.inject({ method: 'GET', url: `/webview/${cap}` });
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toBe(`/webview/${cap}/`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/webviews/probe', () => {
|
||||
it('reports an unreachable target as a normal answer, not a 500', async () => {
|
||||
// Port 1 is reserved and refuses instantly.
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/webviews/probe',
|
||||
payload: { url: 'http://127.0.0.1:1/' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const probe = res.json().data;
|
||||
expect(probe.reachable).toBe(false);
|
||||
expect(probe.recommendedMode).toBe('proxy');
|
||||
});
|
||||
|
||||
it('rejects an invalid URL up front', async () => {
|
||||
const res = await app.inject({ method: 'POST', url: '/api/webviews/probe', payload: { url: 'file:///etc' } });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* The web-tab proxy is exempt from Codeman's cookie auth and its cross-site Origin
|
||||
* guard, because a sandboxed dashboard iframe is opaque-origin: it sends no session
|
||||
* cookie and its writes arrive with `Origin: null`. The capability in the path is
|
||||
* the credential instead.
|
||||
*
|
||||
* That exemption is the security-sensitive part of this feature, so these tests pin
|
||||
* its EDGES: it must apply to a live capability and to nothing else. A regression
|
||||
* here would be an unauthenticated hole into an agent-spawning API.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { registerAuthMiddleware, registerHostGuard, registerSecurityHeaders } from '../src/web/middleware/auth.js';
|
||||
import { webviewCapabilities } from '../src/webview-capabilities.js';
|
||||
import type { HostPolicy } from '../src/web/network-auth-policy.js';
|
||||
|
||||
const POLICY: HostPolicy = { allowedHosts: [], allowLan: true };
|
||||
const PASSWORD = 'test-password';
|
||||
|
||||
let app: FastifyInstance;
|
||||
let capability: string;
|
||||
let savedPassword: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
savedPassword = process.env.CODEMAN_PASSWORD;
|
||||
// The middleware reads this at registration time; auth is inert without it.
|
||||
process.env.CODEMAN_PASSWORD = PASSWORD;
|
||||
|
||||
capability = webviewCapabilities.mint('webview-under-test', undefined);
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
// Same order as server.ts (host guard → auth → security headers), so hook
|
||||
// interactions are exercised for real. The OPTIONS short-circuit lives in
|
||||
// registerSecurityHeaders and is part of what these tests pin.
|
||||
registerHostGuard(app, () => POLICY);
|
||||
registerAuthMiddleware(app, false);
|
||||
registerSecurityHeaders(app, false);
|
||||
|
||||
// Stand-ins for the real surfaces, so a reachable route means auth let it through.
|
||||
app.all('/webview/:cap/*', async () => ({ proxied: true }));
|
||||
app.all('/api/sessions', async () => ({ sensitive: true }));
|
||||
app.get('/', async () => 'app shell');
|
||||
app.get('/static/app.js', async () => 'asset');
|
||||
app.get('/webviewfoo/bar', async () => 'lookalike');
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
webviewCapabilities.revokeWebview('webview-under-test');
|
||||
if (savedPassword === undefined) delete process.env.CODEMAN_PASSWORD;
|
||||
else process.env.CODEMAN_PASSWORD = savedPassword;
|
||||
});
|
||||
|
||||
describe('the exemption applies to a live capability', () => {
|
||||
it('lets an unauthenticated GET through on the proxy path', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: `/webview/${capability}/static/app.js` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('lets a write through despite Origin: null, which a sandboxed iframe always sends', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/webview/${capability}/login`,
|
||||
headers: { origin: 'null' },
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('lets a CORS preflight reach the proxy instead of the global 204 short-circuit', async () => {
|
||||
// registerSecurityHeaders answers every OPTIONS with a bare 204, which carries
|
||||
// no Access-Control-Allow-Origin for the `null` origin a sandboxed frame sends.
|
||||
// The proxy must get the chance to answer with real CORS headers, or every
|
||||
// dashboard fetch fails its preflight.
|
||||
const res = await app.inject({
|
||||
method: 'OPTIONS',
|
||||
url: `/webview/${capability}/api/stats`,
|
||||
headers: { origin: 'null', 'access-control-request-method': 'GET' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200); // reached the stand-in route, not the 204 hook
|
||||
});
|
||||
|
||||
it('still short-circuits OPTIONS everywhere else', async () => {
|
||||
// Authenticated, because the auth hook runs before the security-headers hook
|
||||
// and would otherwise 401 first. With credentials the 204 short-circuit is
|
||||
// reached, proving it is intact for every non-webview path.
|
||||
const res = await app.inject({
|
||||
method: 'OPTIONS',
|
||||
url: '/api/sessions',
|
||||
headers: {
|
||||
origin: 'null',
|
||||
'access-control-request-method': 'GET',
|
||||
authorization: 'Basic ' + Buffer.from(`admin:${PASSWORD}`).toString('base64'),
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(204);
|
||||
expect(res.headers['access-control-allow-origin']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('serves a root-absolute asset when the Referer identifies the dashboard', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/static/app.js',
|
||||
headers: { referer: `http://localhost/webview/${capability}/panel` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the exemption does NOT widen anywhere else', () => {
|
||||
it('rejects an unauthenticated request with no capability at all', async () => {
|
||||
expect((await app.inject({ method: 'GET', url: '/static/app.js' })).statusCode).toBe(401);
|
||||
expect((await app.inject({ method: 'GET', url: '/' })).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects a well-formed but UNKNOWN capability', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: `/webview/${'Z'.repeat(32)}/x` });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects a revoked capability immediately', async () => {
|
||||
webviewCapabilities.revokeWebview('webview-under-test');
|
||||
const res = await app.inject({ method: 'GET', url: `/webview/${capability}/x` });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('does not match a lookalike prefix', async () => {
|
||||
expect((await app.inject({ method: 'GET', url: '/webviewfoo/bar' })).statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('NEVER exempts the Codeman API, even with a valid capability in the Referer', async () => {
|
||||
// This is the hole the Referer form would open if it were not path-fenced.
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/sessions',
|
||||
headers: { referer: `http://localhost/webview/${capability}/panel` },
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('does not let the Referer form carry a WRITE', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/static/app.js',
|
||||
headers: { referer: `http://localhost/webview/${capability}/panel`, origin: 'null' },
|
||||
payload: {},
|
||||
});
|
||||
// Blocked as cross-site by the Origin guard, or as unauthenticated. Either is fine;
|
||||
// what matters is that it is not 200.
|
||||
expect(res.statusCode).not.toBe(200);
|
||||
});
|
||||
|
||||
it('still blocks a genuinely cross-site write to the API', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions',
|
||||
headers: { origin: 'https://evil.example' },
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authenticated access is unaffected', () => {
|
||||
const basic = 'Basic ' + Buffer.from(`admin:${PASSWORD}`).toString('base64');
|
||||
|
||||
it('normal Basic auth still reaches the app', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/', headers: { authorization: basic } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('a wrong password is still rejected', async () => {
|
||||
const wrong = 'Basic ' + Buffer.from('admin:nope').toString('base64');
|
||||
expect((await app.inject({ method: 'GET', url: '/', headers: { authorization: wrong } })).statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,493 @@
|
||||
/**
|
||||
* Pure helpers behind the web-tab reverse proxy (src/web/webview-proxy.ts).
|
||||
*
|
||||
* These cover the rewrites that make an un-embeddable dashboard embeddable, and
|
||||
* the containment checks that keep the proxy from becoming an open relay.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildDownstreamResponseHeaders,
|
||||
buildProxyCorsHeaders,
|
||||
buildUpstreamRequestHeaders,
|
||||
capabilityFromProxyPath,
|
||||
capabilityFromReferer,
|
||||
extractFrameAncestors,
|
||||
filterCookieHeader,
|
||||
isFramableCrossOrigin,
|
||||
isHtmlContentType,
|
||||
isValidWebviewUrl,
|
||||
parseWebviewUrl,
|
||||
proxyPrefixFor,
|
||||
resolveUpstreamUrl,
|
||||
rewriteHtml,
|
||||
rewriteLocation,
|
||||
rewriteSetCookie,
|
||||
runtimeUrlShim,
|
||||
stripFrameAncestors,
|
||||
upstreamWebSocketUrl,
|
||||
} from '../src/web/webview-proxy.js';
|
||||
|
||||
const CAP = 'A'.repeat(32);
|
||||
const PREFIX = `/webview/${CAP}/`;
|
||||
|
||||
describe('parseWebviewUrl', () => {
|
||||
it('accepts plain http and https', () => {
|
||||
expect(parseWebviewUrl('http://127.0.0.1:4000/')?.origin).toBe('http://127.0.0.1:4000');
|
||||
expect(parseWebviewUrl('https://dash.example.com/grafana')?.origin).toBe('https://dash.example.com');
|
||||
});
|
||||
|
||||
it('rejects non-http schemes', () => {
|
||||
for (const url of ['javascript:alert(1)', 'file:///etc/passwd', 'data:text/html,x', 'ftp://host/x']) {
|
||||
expect(parseWebviewUrl(url), url).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects embedded credentials, which would be forwarded and logged', () => {
|
||||
expect(parseWebviewUrl('http://user:pass@host:4000/')).toBeNull();
|
||||
expect(parseWebviewUrl('http://user@host:4000/')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects garbage and empty input', () => {
|
||||
expect(parseWebviewUrl('')).toBeNull();
|
||||
expect(parseWebviewUrl('not a url')).toBeNull();
|
||||
expect(isValidWebviewUrl('http://ok.example')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveUpstreamUrl', () => {
|
||||
const saved = 'http://127.0.0.1:4000/grafana/d/abc?theme=dark';
|
||||
|
||||
it('serves the saved path+query for the landing page', () => {
|
||||
expect(resolveUpstreamUrl(saved, '', '')?.href).toBe('http://127.0.0.1:4000/grafana/d/abc?theme=dark');
|
||||
});
|
||||
|
||||
it('is ORIGIN-scoped, not path-scoped, so root-absolute assets resolve', () => {
|
||||
// The saved /grafana/d/abc path must NOT be prepended, or /public/x.js 404s.
|
||||
expect(resolveUpstreamUrl(saved, 'public/build/app.js', '')?.href).toBe(
|
||||
'http://127.0.0.1:4000/public/build/app.js'
|
||||
);
|
||||
});
|
||||
|
||||
it('carries the query string through', () => {
|
||||
expect(resolveUpstreamUrl(saved, 'api/data', '?from=now-6h')?.href).toBe(
|
||||
'http://127.0.0.1:4000/api/data?from=now-6h'
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses to leave the upstream origin', () => {
|
||||
// Protocol-relative would jump host; traversal would climb out.
|
||||
expect(resolveUpstreamUrl(saved, '/evil.com/x', '')?.origin).toBe('http://127.0.0.1:4000');
|
||||
expect(resolveUpstreamUrl(saved, '//evil.com/x', '')).toBeNull();
|
||||
const climbed = resolveUpstreamUrl(saved, '../../../../etc/passwd', '');
|
||||
expect(climbed?.origin).toBe('http://127.0.0.1:4000');
|
||||
});
|
||||
|
||||
it('returns null for an unusable saved url', () => {
|
||||
expect(resolveUpstreamUrl('javascript:alert(1)', 'x', '')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('capability extraction', () => {
|
||||
it('reads the capability out of a proxy path', () => {
|
||||
expect(capabilityFromProxyPath(`${PREFIX}static/app.js`)).toBe(CAP);
|
||||
expect(capabilityFromProxyPath(PREFIX)).toBe(CAP);
|
||||
expect(capabilityFromProxyPath(`/webview/${CAP}`)).toBe(CAP);
|
||||
});
|
||||
|
||||
it('does not match a lookalike prefix', () => {
|
||||
expect(capabilityFromProxyPath('/webviewfoo/bar')).toBeNull();
|
||||
expect(capabilityFromProxyPath('/api/webviews')).toBeNull();
|
||||
expect(capabilityFromProxyPath('/')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects capabilities of implausible shape', () => {
|
||||
expect(capabilityFromProxyPath('/webview/short/x')).toBeNull();
|
||||
expect(capabilityFromProxyPath('/webview/has spaces here and more/x')).toBeNull();
|
||||
expect(capabilityFromProxyPath('/webview/../../etc/x')).toBeNull();
|
||||
});
|
||||
|
||||
it('reads it from a Referer for the root-absolute asset fallback', () => {
|
||||
expect(capabilityFromReferer(`https://box.ts.net${PREFIX}page`)).toBe(CAP);
|
||||
expect(capabilityFromReferer('https://box.ts.net/')).toBeNull();
|
||||
expect(capabilityFromReferer('not a url')).toBeNull();
|
||||
expect(capabilityFromReferer(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSP handling', () => {
|
||||
it('strips frame-ancestors and keeps every other directive', () => {
|
||||
const csp = "default-src 'self'; frame-ancestors 'none'; script-src 'unsafe-inline'";
|
||||
expect(stripFrameAncestors(csp)).toBe("default-src 'self'; script-src 'unsafe-inline'");
|
||||
});
|
||||
|
||||
it('leaves a policy without frame-ancestors alone', () => {
|
||||
expect(stripFrameAncestors("default-src 'self'")).toBe("default-src 'self'");
|
||||
});
|
||||
|
||||
it('does not confuse a similarly-named directive', () => {
|
||||
expect(stripFrameAncestors("frame-src 'self'; frame-ancestors 'none'")).toBe("frame-src 'self'");
|
||||
});
|
||||
|
||||
it('extracts the directive value for the probe', () => {
|
||||
expect(extractFrameAncestors("default-src 'self'; frame-ancestors https://a.com")).toBe('https://a.com');
|
||||
expect(extractFrameAncestors("default-src 'self'")).toBeUndefined();
|
||||
expect(extractFrameAncestors(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFramableCrossOrigin', () => {
|
||||
it('honours X-Frame-Options', () => {
|
||||
expect(isFramableCrossOrigin('DENY', undefined)).toBe(false);
|
||||
expect(isFramableCrossOrigin('sameorigin', undefined)).toBe(false);
|
||||
expect(isFramableCrossOrigin(undefined, undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats frame-ancestors 'none' and 'self' as not cross-origin framable", () => {
|
||||
expect(isFramableCrossOrigin(undefined, "frame-ancestors 'none'")).toBe(false);
|
||||
expect(isFramableCrossOrigin(undefined, "frame-ancestors 'self'")).toBe(false);
|
||||
});
|
||||
|
||||
it('allows a wildcard or explicit host', () => {
|
||||
expect(isFramableCrossOrigin(undefined, 'frame-ancestors *')).toBe(true);
|
||||
expect(isFramableCrossOrigin(undefined, 'frame-ancestors https://codeman.example')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rewriteLocation', () => {
|
||||
const requestUrl = new URL('http://127.0.0.1:4000/login');
|
||||
|
||||
it('maps a root-absolute redirect into the proxy prefix', () => {
|
||||
expect(rewriteLocation('/dashboard?x=1', requestUrl, CAP)).toBe(`${PREFIX}dashboard?x=1`);
|
||||
});
|
||||
|
||||
it('maps a same-origin absolute redirect', () => {
|
||||
expect(rewriteLocation('http://127.0.0.1:4000/home', requestUrl, CAP)).toBe(`${PREFIX}home`);
|
||||
});
|
||||
|
||||
it('leaves a CROSS-origin redirect alone rather than relaying it', () => {
|
||||
// Relaying would make this an open proxy for any host the upstream names.
|
||||
expect(rewriteLocation('https://evil.example/x', requestUrl, CAP)).toBe('https://evil.example/x');
|
||||
});
|
||||
|
||||
it('preserves the hash', () => {
|
||||
expect(rewriteLocation('/panel#row2', requestUrl, CAP)).toBe(`${PREFIX}panel#row2`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rewriteSetCookie', () => {
|
||||
it('rebases Path onto the proxy prefix and drops Domain', () => {
|
||||
const out = rewriteSetCookie('sid=abc; Path=/; Domain=dash.local; HttpOnly', CAP, true);
|
||||
expect(out).toContain('sid=abc');
|
||||
expect(out).toContain(`Path=${PREFIX}`);
|
||||
expect(out).not.toContain('Domain');
|
||||
expect(out).toContain('HttpOnly');
|
||||
});
|
||||
|
||||
it('adds a scoped Path when the upstream sent none', () => {
|
||||
expect(rewriteSetCookie('sid=abc; HttpOnly', CAP, true)).toContain(`Path=${PREFIX}`);
|
||||
});
|
||||
|
||||
it('drops Secure when Codeman itself is serving plain HTTP', () => {
|
||||
// A Secure cookie over http is silently discarded by the browser.
|
||||
expect(rewriteSetCookie('sid=abc; Path=/; Secure', CAP, false)).not.toMatch(/secure/i);
|
||||
expect(rewriteSetCookie('sid=abc; Path=/; Secure', CAP, true)).toMatch(/Secure/);
|
||||
});
|
||||
|
||||
it('keeps a nested upstream path under the prefix', () => {
|
||||
expect(rewriteSetCookie('sid=abc; Path=/admin', CAP, true)).toContain(`Path=${PREFIX}admin`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterCookieHeader', () => {
|
||||
it("removes Codeman's own session cookie and keeps the dashboard's", () => {
|
||||
expect(filterCookieHeader('codeman_session=SECRET; dash=1; other=2', ['codeman_session'])).toBe('dash=1; other=2');
|
||||
});
|
||||
|
||||
it('returns undefined when nothing survives', () => {
|
||||
expect(filterCookieHeader('codeman_session=SECRET', ['codeman_session'])).toBeUndefined();
|
||||
expect(filterCookieHeader(undefined, ['codeman_session'])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildUpstreamRequestHeaders', () => {
|
||||
const upstream = new URL('http://127.0.0.1:4000/panel');
|
||||
|
||||
it('NEVER forwards Codeman credentials to the dashboard', () => {
|
||||
const headers = buildUpstreamRequestHeaders(
|
||||
{ authorization: 'Basic CODEMANCREDS', cookie: 'codeman_session=SECRET; dash=1', accept: '*/*' },
|
||||
upstream,
|
||||
{ forwardCookies: false, sessionCookieName: 'codeman_session' }
|
||||
);
|
||||
expect(headers.authorization).toBeUndefined();
|
||||
expect(headers.cookie).toBeUndefined();
|
||||
expect(headers.accept).toBe('*/*');
|
||||
});
|
||||
|
||||
it('forwards the dashboard cookies but strips the session cookie in trusted mode', () => {
|
||||
const headers = buildUpstreamRequestHeaders({ cookie: 'codeman_session=SECRET; dash=1' }, upstream, {
|
||||
forwardCookies: true,
|
||||
sessionCookieName: 'codeman_session',
|
||||
});
|
||||
expect(headers.cookie).toBe('dash=1');
|
||||
expect(headers.authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it('presents Origin/Referer as if the browser talked to the dashboard directly', () => {
|
||||
const headers = buildUpstreamRequestHeaders({ origin: 'https://codeman.local' }, upstream, {
|
||||
forwardCookies: false,
|
||||
sessionCookieName: 'codeman_session',
|
||||
});
|
||||
expect(headers.origin).toBe('http://127.0.0.1:4000');
|
||||
expect(headers.referer).toBe('http://127.0.0.1:4000/panel');
|
||||
});
|
||||
|
||||
it('drops hop-by-hop and recomputed headers', () => {
|
||||
const headers = buildUpstreamRequestHeaders(
|
||||
{ host: 'codeman.local', connection: 'keep-alive', 'transfer-encoding': 'chunked', 'content-length': '5' },
|
||||
upstream,
|
||||
{ forwardCookies: false, sessionCookieName: 'codeman_session' }
|
||||
);
|
||||
expect(headers.host).toBeUndefined();
|
||||
expect(headers.connection).toBeUndefined();
|
||||
expect(headers['transfer-encoding']).toBeUndefined();
|
||||
expect(headers['content-length']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDownstreamResponseHeaders', () => {
|
||||
const requestUrl = new URL('http://127.0.0.1:4000/panel');
|
||||
const build = (entries: Array<[string, string]>, cookies: string[] = []) =>
|
||||
buildDownstreamResponseHeaders(entries, cookies, CAP, requestUrl, true);
|
||||
|
||||
it('strips the framing refusal, which is the whole point of the proxy', () => {
|
||||
const { headers } = build([
|
||||
['x-frame-options', 'DENY'],
|
||||
['content-type', 'text/html'],
|
||||
]);
|
||||
expect(headers['x-frame-options']).toBeUndefined();
|
||||
expect(headers['content-type']).toBe('text/html');
|
||||
});
|
||||
|
||||
it('drops content-encoding/length because undici already decoded the body', () => {
|
||||
// Forwarding these makes the browser try to gunzip plaintext.
|
||||
const { headers } = build([
|
||||
['content-encoding', 'gzip'],
|
||||
['content-length', '1234'],
|
||||
]);
|
||||
expect(headers['content-encoding']).toBeUndefined();
|
||||
expect(headers['content-length']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the upstream CSP minus frame-ancestors, and null when there was none', () => {
|
||||
expect(build([['content-security-policy', "default-src 'self'; frame-ancestors 'none'"]]).csp).toBe(
|
||||
"default-src 'self'"
|
||||
);
|
||||
expect(build([['content-type', 'text/css']]).csp).toBeNull();
|
||||
});
|
||||
|
||||
it('rewrites Location and Set-Cookie', () => {
|
||||
const { headers, setCookie } = build([['location', '/next']], ['sid=1; Path=/']);
|
||||
expect(headers.location).toBe(`${PREFIX}next`);
|
||||
expect(setCookie).toHaveLength(1);
|
||||
expect(setCookie[0]).toContain(`Path=${PREFIX}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rewriteHtml', () => {
|
||||
it('injects <base> immediately after <head>', () => {
|
||||
const out = rewriteHtml('<html><head><title>x</title></head><body></body></html>', CAP);
|
||||
expect(out).toContain(`<head><base href="${PREFIX}">`);
|
||||
});
|
||||
|
||||
it('falls back to <html>, then to the very start, for malformed documents', () => {
|
||||
expect(rewriteHtml('<html><body>hi</body></html>', CAP)).toContain(`<html><base href="${PREFIX}">`);
|
||||
const bare = rewriteHtml('just text', CAP);
|
||||
expect(bare.startsWith(`<base href="${PREFIX}">`)).toBe(true);
|
||||
expect(bare.endsWith('just text')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not add a second <base> when the page already has one', () => {
|
||||
const out = rewriteHtml('<html><head><base href="/x/"></head></html>', CAP);
|
||||
expect(out.match(/<base/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('still injects the runtime shim when the page ships its own <base>', () => {
|
||||
// The shim is the only layer that catches runtime-built URLs, so an early
|
||||
// return on an existing <base> would silently break those pages.
|
||||
const out = rewriteHtml('<html><head><base href="/x/"></head></html>', CAP);
|
||||
expect(out).toContain('<script>');
|
||||
expect(out).toContain(PREFIX);
|
||||
});
|
||||
|
||||
it('injects the shim into every rewritten document', () => {
|
||||
expect(rewriteHtml('<html><head></head></html>', CAP)).toContain('<script>');
|
||||
expect(rewriteHtml('just text', CAP)).toContain('<script>');
|
||||
});
|
||||
|
||||
it('rebases root-absolute src/href/action, which <base> cannot fix', () => {
|
||||
const out = rewriteHtml(
|
||||
`<head></head><body><script src="/static/app.js"></script><link href='/s.css'><form action="/login"></form></body>`,
|
||||
CAP
|
||||
);
|
||||
expect(out).toContain(`src="${PREFIX}static/app.js"`);
|
||||
expect(out).toContain(`href='${PREFIX}s.css'`);
|
||||
expect(out).toContain(`action="${PREFIX}login"`);
|
||||
});
|
||||
|
||||
it('leaves protocol-relative and absolute URLs alone', () => {
|
||||
const out = rewriteHtml('<head></head><script src="//cdn.example/x.js"></script><img src="https://a/b.png">', CAP);
|
||||
expect(out).toContain('src="//cdn.example/x.js"');
|
||||
expect(out).toContain('src="https://a/b.png"');
|
||||
});
|
||||
|
||||
it('is stable across repeated calls (no shared regex lastIndex)', () => {
|
||||
const html = '<head></head><script src="/a.js"></script>';
|
||||
expect(rewriteHtml(html, CAP)).toBe(rewriteHtml(html, CAP));
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildProxyCorsHeaders', () => {
|
||||
it('echoes the opaque origin a sandboxed frame sends', () => {
|
||||
// Without this the browser rejects every dashboard fetch with an opaque
|
||||
// net::ERR_FAILED, while the page itself renders fine.
|
||||
const h = buildProxyCorsHeaders('null');
|
||||
expect(h['access-control-allow-origin']).toBe('null');
|
||||
expect(h.vary).toBe('Origin');
|
||||
});
|
||||
|
||||
it('omits allow-credentials for a null origin, which browsers reject together', () => {
|
||||
expect(buildProxyCorsHeaders('null')['access-control-allow-credentials']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows credentials for a real origin (trusted mode)', () => {
|
||||
const h = buildProxyCorsHeaders('https://codeman.local');
|
||||
expect(h['access-control-allow-origin']).toBe('https://codeman.local');
|
||||
expect(h['access-control-allow-credentials']).toBe('true');
|
||||
});
|
||||
|
||||
it('echoes requested headers on a preflight', () => {
|
||||
expect(buildProxyCorsHeaders('null', 'content-type, x-token')['access-control-allow-headers']).toBe(
|
||||
'content-type, x-token'
|
||||
);
|
||||
expect(buildProxyCorsHeaders('null')['access-control-allow-headers']).toBe('*');
|
||||
});
|
||||
|
||||
it('emits nothing when the request carries no Origin', () => {
|
||||
expect(buildProxyCorsHeaders(undefined)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtimeUrlShim', () => {
|
||||
const shim = runtimeUrlShim(PREFIX);
|
||||
const body = shim.replace(/^<script>/, '').replace(/<\/script>$/, '');
|
||||
|
||||
it('emits a parseable script', () => {
|
||||
expect(shim.startsWith('<script>')).toBe(true);
|
||||
expect(shim.endsWith('</script>')).toBe(true);
|
||||
expect(() => new Function(body)).not.toThrow();
|
||||
});
|
||||
|
||||
it('contains no bare </script> that would close the tag early', () => {
|
||||
expect(/<\/script>/i.test(body)).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Execute the shim against a fake window and return the patched globals, so the
|
||||
* rewrite logic is tested for real rather than by reading the source.
|
||||
*/
|
||||
function runShim(host = 'codeman.local') {
|
||||
const calls: string[] = [];
|
||||
const win: Record<string, unknown> = {
|
||||
fetch: (input: unknown) => {
|
||||
calls.push(String(typeof input === 'object' && input ? (input as { url: string }).url : input));
|
||||
return Promise.resolve();
|
||||
},
|
||||
XMLHttpRequest: function () {} as unknown as { prototype: Record<string, unknown> },
|
||||
WebSocket: class {
|
||||
url: string;
|
||||
constructor(u: string) {
|
||||
this.url = u;
|
||||
calls.push(u);
|
||||
}
|
||||
},
|
||||
EventSource: class {
|
||||
url: string;
|
||||
constructor(u: string) {
|
||||
this.url = u;
|
||||
calls.push(u);
|
||||
}
|
||||
},
|
||||
};
|
||||
(win.XMLHttpRequest as { prototype: Record<string, unknown> }).prototype = {
|
||||
open(_m: string, u: string) {
|
||||
calls.push(u);
|
||||
},
|
||||
};
|
||||
const location = { href: `https://${host}${PREFIX}page`, host };
|
||||
new Function('window', 'location', 'URL', 'Request', `with (window) { ${body} }`)(win, location, URL, undefined);
|
||||
return { win, calls };
|
||||
}
|
||||
|
||||
it('rewrites a ROOT-ABSOLUTE fetch, the case <base> cannot reach', () => {
|
||||
const { win, calls } = runShim();
|
||||
(win.fetch as (u: string) => void)('/api/carousel/job?id=1');
|
||||
expect(calls[0]).toBe(`${PREFIX}api/carousel/job?id=1`);
|
||||
});
|
||||
|
||||
it('leaves relative URLs alone (<base> already handles them)', () => {
|
||||
const { win, calls } = runShim();
|
||||
(win.fetch as (u: string) => void)('api/data');
|
||||
expect(calls[0]).toBe('api/data');
|
||||
});
|
||||
|
||||
it('does not double-prefix an already-proxied URL', () => {
|
||||
const { win, calls } = runShim();
|
||||
(win.fetch as (u: string) => void)(`${PREFIX}api/data`);
|
||||
expect(calls[0]).toBe(`${PREFIX}api/data`);
|
||||
});
|
||||
|
||||
it('leaves cross-origin URLs alone', () => {
|
||||
const { win, calls } = runShim();
|
||||
(win.fetch as (u: string) => void)('https://cdn.example/lib.js');
|
||||
expect(calls[0]).toBe('https://cdn.example/lib.js');
|
||||
});
|
||||
|
||||
it('rewrites a same-origin ABSOLUTE URL built from location', () => {
|
||||
const { win, calls } = runShim();
|
||||
(win.fetch as (u: string) => void)('https://codeman.local/api/data');
|
||||
expect(calls[0]).toBe(`https://codeman.local${PREFIX}api/data`);
|
||||
});
|
||||
|
||||
it('patches XMLHttpRequest.open', () => {
|
||||
const { win, calls } = runShim();
|
||||
const xhr = win.XMLHttpRequest as { prototype: { open: (m: string, u: string) => void } };
|
||||
xhr.prototype.open.call({}, 'GET', '/api/data');
|
||||
expect(calls[0]).toBe(`${PREFIX}api/data`);
|
||||
});
|
||||
|
||||
it('patches WebSocket and EventSource', () => {
|
||||
const { win, calls } = runShim();
|
||||
new (win.WebSocket as new (u: string) => unknown)('/live');
|
||||
new (win.EventSource as new (u: string) => unknown)('/events');
|
||||
expect(calls).toEqual([`${PREFIX}live`, `${PREFIX}events`]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('misc helpers', () => {
|
||||
it('identifies HTML content types, parameters included', () => {
|
||||
expect(isHtmlContentType('text/html; charset=utf-8')).toBe(true);
|
||||
expect(isHtmlContentType('application/xhtml+xml')).toBe(true);
|
||||
expect(isHtmlContentType('application/json')).toBe(false);
|
||||
expect(isHtmlContentType(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('maps http(s) to ws(s) for the socket leg', () => {
|
||||
expect(upstreamWebSocketUrl(new URL('http://h:4000/live'))).toBe('ws://h:4000/live');
|
||||
expect(upstreamWebSocketUrl(new URL('https://h/live'))).toBe('wss://h/live');
|
||||
});
|
||||
|
||||
it('builds the iframe prefix', () => {
|
||||
expect(proxyPrefixFor(CAP)).toBe(PREFIX);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user