merge master into light-skins

This commit is contained in:
Codeman maintainer
2026-07-28 00:09:34 +02:00
44 changed files with 3897 additions and 166 deletions
+2 -2
View File
@@ -4,7 +4,7 @@ Codeman launches AI coding sessions with `--dangerously-skip-permissions`, so th
web UI is **by design a remote-code-execution surface for whoever can reach it**.
The entire security model exists to control *who* that is. Please read this before
exposing an instance beyond `localhost`. The full model lives in
[`docs/security-architecture.md`](docs/security-architecture.md).
[`docs/security-architecture.md`](../docs/security-architecture.md).
## Supported versions
@@ -75,4 +75,4 @@ subscribe and send time), and tmux session names discovered on the shared socket
are validated against the safe-name pattern before reaching any shell call site.
For the detailed rationale, defenses, and recommended secure setups, see
[`docs/security-architecture.md`](docs/security-architecture.md).
[`docs/security-architecture.md`](../docs/security-architecture.md).
+3
View File
@@ -26,3 +26,6 @@ src/web/public/terminal-ui.js
src/web/public/voice-input.js
src/web/public/upload.html
scripts/remotion/
# Hand-maintained; Prettier escapes underscores in glob paths and corrupts paragraphs.
CLAUDE.md
-8
View File
@@ -1,8 +0,0 @@
{
"singleQuote": true,
"semi": true,
"tabWidth": 2,
"printWidth": 120,
"trailingComma": "es5",
"endOfLine": "lf"
}
+35
View File
@@ -1,5 +1,40 @@
# 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
- Mobile toolbar: a dedicated Enter button, and Shell moves into the Run dropdown.
Submitting is a constant need on a touch keyboard, so on phones (≤430px) the toolbar slot that held "Shell" now holds a dark blue **Enter** button. Starting a shell, the far rarer action, moves into the expandable Run dropdown as `Terminal / Shell` (the Run button then reads "Run SH"). Desktop and tablet are unchanged: the green Run Shell button stays exactly where it was.
Enter is replayed through the terminal's own input path rather than posted to the input API. This matters because local echo is on by default on touch devices: the characters you type are buffered client-side and have not yet reached the PTY, so sending a bare carriage return would submit an empty line and leave your text stranded on screen. Replaying the keypress flushes the buffered text first, then submits.
Installer: re-runs and updates now preserve the existing network binding instead of silently reverting it, so upgrading no longer changes how the dashboard is reachable.
Default desktop header is cleaner: the file viewer is shown by default and the plan-usage chip is unchanged, while the token-count chip and lifecycle-log button now default off. Stored preferences are still honored.
Docs and repo housekeeping: fresh phone screenshots and a new hero GIF in both READMEs, contributor and total-commit badges, and a much shorter repo root. `SECURITY.md` moved to `.github/` (GitHub resolves it there, so the Security policy tab is unaffected), `SPEEDRUN.md` to `docs/`, the knip config to `config/`, and Prettier's config into the `"prettier"` key of `package.json`. `CLAUDE.md` was split so the always-loaded guidance is roughly half its former size, with the deep implementation detail preserved verbatim in `docs/architecture-invariants.md`.
## 1.8.0
### Minor Changes
+117 -90
View File
File diff suppressed because one or more lines are too long
+10 -3
View File
@@ -16,6 +16,8 @@
<img src="https://img.shields.io/badge/Tests-2861%20total-22c55e?style=flat-square" alt="Tests">
<a href="https://www.npmjs.com/package/aicodeman"><img src="https://img.shields.io/npm/v/aicodeman?style=flat-square&label=npm&color=22c55e" alt="npm version"></a>
<a href="https://github.com/Ark0N/Codeman/stargazers"><img src="https://img.shields.io/github/stars/Ark0N/Codeman?style=flat-square&color=eab308" alt="GitHub stars"></a>
<a href="https://github.com/Ark0N/Codeman/graphs/contributors"><img src="https://img.shields.io/github/contributors/Ark0N/Codeman?style=flat-square&color=3b82f6" alt="Contributors"></a>
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
</p>
<p align="center">
@@ -217,12 +219,12 @@ The most responsive AI coding agent experience on any phone. Full xterm.js termi
<table>
<tr>
<td align="center" width="33%"><img src="docs/screenshots/mobile-landing-qr.png" alt="Mobile — landing page with QR auth" width="260"></td>
<td align="center" width="33%"><img src="docs/screenshots/mobile-session-idle.png" alt="Mobile — idle session with keyboard accessory" width="260"></td>
<td align="center" width="33%"><img src="docs/screenshots/mobile-session-keyboard-20260727.png" alt="Mobile — answering an agent's plan prompt with the keyboard accessory bar and Enter button" width="260"></td>
<td align="center" width="33%"><img src="docs/screenshots/mobile-session-active.png" alt="Mobile — active agent session" width="260"></td>
</tr>
<tr>
<td align="center"><em>Landing page with QR auth</em></td>
<td align="center"><em>Keyboard accessory bar</em></td>
<td align="center"><em>Answering prompts by touch</em></td>
<td align="center"><em>Agent working in real-time</em></td>
</tr>
</table>
@@ -252,7 +254,12 @@ The security design addresses all 6 critical QR auth flaws identified in ["Demys
### Touch-Optimized Interface
<p align="center">
<img src="docs/screenshots/mobile-toolbar-enter-20260727.png" alt="Mobile toolbar: accessory bar with /init, /clear, clipboard and Esc above the Run, case, stop, Enter, voice and settings controls" width="560">
</p>
- **Keyboard accessory bar** — `/init`, `/clear`, `/compact` quick-action buttons above the virtual keyboard. Destructive commands (`/clear`, `/compact`) require a double-press to confirm — first tap arms the button, second tap executes — so you never fire one by accident on a bumpy commute
- **Dedicated Enter button** — submitting is a constant need on a touch keyboard, so the phone toolbar gives it a button of its own. It replays the keypress through the terminal, so text buffered by local echo is flushed first rather than stranded. Starting a shell moves into the Run dropdown (`Terminal / Shell`), which is the rarer action
- **Swipe navigation** — left/right on the terminal to switch sessions (80px threshold, 300ms)
- **Smart keyboard handling** — toolbar and terminal shift up when keyboard opens (uses `visualViewport` API with 100px threshold for iOS address bar drift)
- **Safe area support** — respects iPhone notch and home indicator via `env(safe-area-inset-*)`
@@ -597,7 +604,7 @@ When someone authenticates via QR, the desktop shows a notification toast with t
## Security
By default Codeman launches sessions with `--dangerously-skip-permissions`, so the web UI is by design a remote-code-execution surface for whoever can reach it — the whole security model exists to control _who_ that is. (The startup permission mode is configurable; see below.) Recent hardening (v0.9.0 + v0.9.5) closes the browser-driven attack paths that bite self-hosted dev tools. Full model: [`docs/security-architecture.md`](docs/security-architecture.md). **Found a vulnerability?** See [`SECURITY.md`](SECURITY.md) for private disclosure and the list of known limitations.
By default Codeman launches sessions with `--dangerously-skip-permissions`, so the web UI is by design a remote-code-execution surface for whoever can reach it — the whole security model exists to control _who_ that is. (The startup permission mode is configurable; see below.) Recent hardening (v0.9.0 + v0.9.5) closes the browser-driven attack paths that bite self-hosted dev tools. Full model: [`docs/security-architecture.md`](docs/security-architecture.md). **Found a vulnerability?** See [`SECURITY.md`](.github/SECURITY.md) for private disclosure and the list of known limitations.
### Network & access
+10 -3
View File
@@ -18,6 +18,8 @@
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-5.9-3b82f6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript 5.9"></a>
<a href="https://fastify.dev/"><img src="https://img.shields.io/badge/Fastify-5.x-1e3a5f?style=flat-square&logo=fastify&logoColor=white" alt="Fastify"></a>
<img src="https://img.shields.io/badge/Tests-2861%20total-22c55e?style=flat-square" alt="Tests">
<a href="https://github.com/Ark0N/Codeman/graphs/contributors"><img src="https://img.shields.io/github/contributors/Ark0N/Codeman?style=flat-square&color=3b82f6" alt="Contributors"></a>
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
</p>
<p align="center">
@@ -207,12 +209,12 @@ codeman web -H 0.0.0.0 # 绑定局域网 —— 必须设置 CODEMAN_
<table>
<tr>
<td align="center" width="33%"><img src="docs/screenshots/mobile-landing-qr.png" alt="移动端 — 带二维码认证的登录页" width="260"></td>
<td align="center" width="33%"><img src="docs/screenshots/mobile-session-idle.png" alt="移动端 — 带键盘配件栏的空闲会话" width="260"></td>
<td align="center" width="33%"><img src="docs/screenshots/mobile-session-keyboard-20260727.png" alt="移动端 — 通过键盘配件栏与 Enter 按钮回答智能体的方案提示" width="260"></td>
<td align="center" width="33%"><img src="docs/screenshots/mobile-session-active.png" alt="移动端 — 活动中的智能体会话" width="260"></td>
</tr>
<tr>
<td align="center"><em>带二维码认证的登录页</em></td>
<td align="center"><em>键盘配件栏</em></td>
<td align="center"><em>触控回答提示</em></td>
<td align="center"><em>智能体实时工作中</em></td>
</tr>
</table>
@@ -242,7 +244,12 @@ codeman web -H 0.0.0.0 # 绑定局域网 —— 必须设置 CODEMAN_
### 触控优化界面
<p align="center">
<img src="docs/screenshots/mobile-toolbar-enter-20260727.png" alt="移动端工具栏:配件栏的 /init、/clear、剪贴板与 Esc,下方是 Run、案例、停止、Enter、语音与设置控件" width="560">
</p>
- **键盘配件栏** —— 在虚拟键盘上方提供 `/init`、`/clear`、`/compact` 快捷按钮。破坏性命令(`/clear`、`/compact`)需双击确认 —— 第一次点击「上膛」,第二次点击执行 —— 这样在颠簸的通勤路上也不会误触
- **独立的 Enter 按钮** —— 在触控键盘上提交是高频操作,因此手机工具栏为它单独设了一个按钮。它会以按键的方式回放,从而先冲刷本地回显缓冲的文本,不会让内容滞留在屏幕上。启动 Shell 这类低频操作则移入 Run 下拉菜单(`Terminal / Shell`)
- **滑动导航** —— 在终端上左右滑动切换会话(阈值 80px,300ms)
- **智能键盘处理** —— 键盘弹出时工具栏与终端整体上移(使用 `visualViewport` API,并对 iOS 地址栏漂移设置 100px 阈值)
- **安全区适配** —— 通过 `env(safe-area-inset-*)` 适配 iPhone 刘海与底部 Home 指示条
@@ -587,7 +594,7 @@ URL 被刻意保持精简(`/q/` 路径 + 6 字符码 ≈ 53–56 个字符)
## 安全
Codeman 默认用 `--dangerously-skip-permissions` 启动会话,因此 Web UI 在设计上对任何能访问到它的人都是一个远程代码执行面 —— 整套安全模型的存在就是为了控制*谁*能访问。(启动权限模式可配置,见下文。)近期加固(v0.9.0 + v0.9.5)封堵了那些常困扰自托管开发工具的浏览器驱动攻击路径。完整模型:[`docs/security-architecture.md`](docs/security-architecture.md)。**发现了漏洞?** 私下披露方式与已知限制清单见 [`SECURITY.md`](SECURITY.md)。
Codeman 默认用 `--dangerously-skip-permissions` 启动会话,因此 Web UI 在设计上对任何能访问到它的人都是一个远程代码执行面 —— 整套安全模型的存在就是为了控制*谁*能访问。(启动权限模式可配置,见下文。)近期加固(v0.9.0 + v0.9.5)封堵了那些常困扰自托管开发工具的浏览器驱动攻击路径。完整模型:[`docs/security-architecture.md`](docs/security-architecture.md)。**发现了漏洞?** 私下披露方式与已知限制清单见 [`SECURITY.md`](.github/SECURITY.md)。
### 网络与访问
View File
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 MiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 661 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

+10
View File
@@ -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 |
+1 -1
View File
@@ -75,5 +75,5 @@ allowance. The commitments above take effect at `1.0.0`.
## See also
- `CLAUDE.md` — the COM release workflow (changesets, version bump, deploy)
- `SECURITY.md` — security reporting and the supported-version policy
- `.github/SECURITY.md` — security reporting and the supported-version policy
- `docs/security-architecture.md` — the full trust model
+137
View File
@@ -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` |
+3 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.8.0",
"version": "1.8.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.8.0",
"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": {
+11 -2
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.8.0",
"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",
@@ -32,9 +32,17 @@
"changeset": "changeset",
"version-packages": "changeset version && npm install --package-lock-only && node scripts/check-lockfile-sync.mjs",
"check:lockfile": "node scripts/check-lockfile-sync.mjs",
"knip": "npx --yes knip@latest",
"knip": "npx --yes knip@latest --config config/knip.json",
"release": "changeset publish"
},
"prettier": {
"singleQuote": true,
"semi": true,
"tabWidth": 2,
"printWidth": 120,
"trailingComma": "es5",
"endOfLine": "lf"
},
"workspaces": [
".",
"packages/*"
@@ -75,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": {
+49
View File
@@ -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';
+1
View File
@@ -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';
+87
View File
@@ -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;
}
+79 -3
View File
@@ -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
View File
@@ -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 {}
+3
View File
@@ -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',
};
// ═══════════════════════════════════════════════════════════════
+74
View File
@@ -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">
@@ -460,6 +465,18 @@
<span class="run-mode-dot gemini"></span>Gemini
</button>
<div class="run-mode-sep"></div>
<button class="run-mode-option" data-mode="shell" onclick="app.setRunMode('shell')">
<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&hellip;
</button>
<div class="run-mode-sep"></div>
<div class="run-mode-header">Recent Sessions</div>
<div class="run-mode-history" id="runModeHistory"></div>
</div>
@@ -475,6 +492,12 @@
<button class="btn-toolbar btn-shell" onclick="app.runShell()" title="Run Shell">
Run Shell
</button>
<!-- Phone-only: replaces the Shell button on ≤430px (Shell moves into the Run
dropdown there). Sends a bare Enter to the active session, the complement
to the accessory bar's Esc. Hidden everywhere else — see styles.css. -->
<button class="btn-toolbar btn-enter" onclick="app.sendEnterKey()" title="Send Enter">
Enter
</button>
<div class="tab-count-group" title="Instance count">
<button class="tab-count-btn" onclick="app.decrementShellCount()">−</button>
<input type="number" id="shellCount" class="tab-count-input" value="1" min="1" max="20" readonly>
@@ -629,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">&times;</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>
@@ -2549,6 +2622,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>
+36 -26
View File
@@ -875,19 +875,44 @@ html.mobile-init .file-browser-panel {
margin-right: 0;
}
/* Secondary action - Run Shell - right side */
/* Shell is NOT a toolbar button on phones — it moved into the Run dropdown
(Terminal / Shell), freeing this slot for Enter. Starting a shell is a rare,
deliberate act; sending Enter is a constant one, so the scarce phone real
estate goes to Enter. */
.btn-toolbar.btn-shell {
flex: 0 0 auto;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
color: #9ca3af;
order: 4; /* Right position */
display: none !important;
}
.btn-toolbar.btn-shell:hover,
.btn-toolbar.btn-shell:active {
background: rgba(255, 255, 255, 0.1);
color: #fff;
/* Secondary action - Enter - right side. Takes the slot (and the order) the
Shell button used to hold, so the toolbar rhythm is unchanged. */
.btn-toolbar.btn-enter {
display: flex !important;
flex: 0 0 auto;
align-items: center;
justify-content: center;
min-width: 54px;
width: 54px;
white-space: nowrap;
padding: 0 8px !important;
overflow: hidden;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.01em;
/* !important is REQUIRED here, not defensive habit: styles.css nests its skin
overrides inside `html:not([data-skin="og"]) { … }`, so a plain `.btn-toolbar`
in that block resolves to (0,2,1) and outranks this (0,2,0) rule. Without
!important the button silently renders in generic toolbar grey. */
background: rgba(30, 58, 95, 0.85) !important;
border: 1px solid rgba(59, 130, 246, 0.45) !important;
color: #dbeafe !important;
order: 4; /* Right position — same slot Shell used to occupy */
}
.btn-toolbar.btn-enter:hover,
.btn-toolbar.btn-enter:active {
background: rgba(37, 74, 122, 0.95) !important;
border-color: rgba(59, 130, 246, 0.7) !important;
color: #fff !important;
}
/* Hide case selector on mobile - simplified toolbar */
@@ -895,27 +920,12 @@ html.mobile-init .file-browser-panel {
display: none !important;
}
/* Simplified toolbar layout — Run, Shell, and Case */
/* Simplified toolbar layout — Run, Enter, and Case */
.toolbar-left .toolbar-group:first-child {
width: 100%;
gap: 8px;
}
.btn-toolbar.btn-shell {
flex: 0 0 auto;
min-width: 54px;
width: 54px;
white-space: nowrap;
padding: 0 8px !important;
overflow: hidden;
font-size: 0 !important;
}
.btn-toolbar.btn-shell::after {
content: "Shell";
font-size: 0.65rem;
}
/* Mobile case button - visible on mobile */
.btn-toolbar.btn-case-mobile {
display: flex !important;
+26 -1
View File
@@ -394,6 +394,9 @@ Object.assign(CodemanApp.prototype, {
if (mode === 'gemini') {
return await this.runGemini();
}
if (mode === 'shell') {
return await this.runShell();
}
return await this.runClaude();
} finally {
const remaining = minLockMs - (Date.now() - startedAt);
@@ -500,10 +503,32 @@ Object.assign(CodemanApp.prototype, {
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
}
if (label) {
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : 'Run';
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'shell' ? 'Run SH' : 'Run';
}
},
/** Send Enter to the active session (phone toolbar button).
*
* MUST go through xterm's onData path, NOT straight to sendInput()/the API.
* With local echo on (the mobile default) the characters you typed are still
* buffered in the LocalEchoOverlay and have NEVER reached the PTY. The onData
* Enter branch (terminal-ui.js) is what flushes that pending text and only
* then sends \r. Send a bare \r instead and you submit an empty line while the
* typed text stays stranded on screen — which reads as "the button does
* nothing". triggerDataEvent replays it exactly as if the key were pressed,
* so overlay flush, flushed-offset cleanup and ordering are all reused. */
sendEnterKey() {
if (!this.activeSessionId) return;
const coreService = this.terminal?._core?.coreService;
if (coreService && typeof coreService.triggerDataEvent === 'function') {
coreService.triggerDataEvent('\r', true);
return;
}
// Fallback only if xterm's private core API moves: correct when local echo
// is off, and still better than doing nothing.
this.sendInput('\r');
},
_initRunMode() {
try { this._runMode = localStorage.getItem('codeman_runMode') || 'claude'; } catch { this._runMode = 'claude'; }
this._applyRunMode();
+130
View File
@@ -3297,6 +3297,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
@@ -3538,6 +3544,14 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
.run-mode-dot.opencode { background: #10b981; }
.run-mode-dot.codex { background: #a855f7; }
.run-mode-dot.gemini { background: #8ab4f8; }
.run-mode-dot.shell { background: #94a3b8; }
/* Phone-only Enter button (see index.html). Hidden by default at every width;
mobile.css turns it on inside @media (max-width: 430px), where it takes over
the slot the Shell button occupies on wider screens. */
.btn-toolbar.btn-enter {
display: none;
}
.run-mode-sep {
height: 1px;
@@ -11782,6 +11796,7 @@ html:not([data-skin="og"]) {
.run-mode-dot.claude { background: var(--accent); }
.run-mode-dot.opencode { background: var(--accent-soft); }
.run-mode-dot.codex { background: var(--accent-grad-b); }
.run-mode-dot.shell { background: var(--text-dim); }
/* ---- Shell button: quiet neutral with a calm green tint ---- */
.btn-toolbar.btn-shell {
@@ -12009,3 +12024,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; }
+91 -16
View File
@@ -982,8 +982,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);
@@ -993,22 +1051,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');
@@ -1030,31 +1093,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() {
+445
View File
@@ -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">&#x2699;</span>
<span class="tab-close" onclick="event.stopPropagation(); app.closeWebviewTab(${jsonId})" title="Close tab" aria-label="Close web tab" tabindex="0">&times;</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();
},
});
+1
View File
@@ -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';
+629
View File
@@ -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');
});
})();
}
+40
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+507
View File
@@ -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;
}
+116
View File
@@ -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();
+37
View File
@@ -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));
}
+55
View File
@@ -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
+216
View File
@@ -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);
});
});
+180
View File
@@ -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);
});
});
+493
View File
@@ -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);
});
});