Adds a "Web / URL" section to the Run dropdown. A saved URL renders as a tab in
the same strip as Claude/Codex/Gemini sessions, with the same Alt+1..9 numbering,
so Codeman is one mission control instead of Codeman plus a pile of browser tabs.
A webview is NOT a sixth SessionMode: no PTY, no tmux, no respawn, no idle
detection. It is a separate resource sharing only the tab strip and the main
content area, the same call that keeps Docker and remote-SSH as case overlays.
Dashboards are proxied through Codeman's own origin, because a direct iframe
fails three ways at once in the shipped deployment: prod serves HTTPS behind
tailscale serve, so http:// targets are hard-blocked as mixed content (with no
override at all on iOS Safari); Grafana/Portainer-class dashboards send
X-Frame-Options: DENY; and our own default-src 'self' CSP blocks cross-origin
frames. Proxying dissolves all three and leaves the production CSP byte-for-byte
unchanged, since /webview/... is already covered by 'self'. A useful side effect:
the fetch happens server-side, so a tailnet-only dashboard is reachable from a
phone that is not on the tailnet.
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 correspondingly exempt from the cookie and Origin checks, because
a sandboxed iframe is opaque-origin: it sends no SameSite=lax cookie and its
writes arrive with Origin: null. The Host allowlist is never bypassed. A second
Referer-keyed form of the exemption exists for root-absolute assets and is fenced
to safe methods on non-/api, non-/ws, non-/q paths.
Iframes omit allow-same-origin unless a URL is explicitly marked trusted, since a
proxied page is served from Codeman's own origin and could otherwise read this
document and drive the agent-spawning API. Authorization and codeman_session are
stripped upstream in BOTH modes, so CODEMAN_PASSWORD cannot leak into a dashboard.
Two things only a real browser reveals, both presenting as the dashboard's own
"Failed to fetch" while the page itself renders fine:
- Runtime-built root-absolute URLs (fetch('/api/data')) escape <base href> and
land on Codeman's root. Widening the Referer fallback into /api would trade
security for it, so an injected shim patches fetch/XHR/WebSocket/EventSource
inside the frame instead, removing the class rather than the guard.
- An opaque-origin document CORS-checks every request, including to the host it
was served from. Script/css/img loads are not CORS-checked, which is why the
page renders while its API calls die. The proxy now emits CORS headers and
answers preflights itself. registerSecurityHeaders answered every OPTIONS with
a bare 204 before routing, carrying no ACAO for Origin: null, so that
short-circuit now exempts a valid capability.
Neither is reproducible with curl, which does not enforce CORS.
Also fixes a pre-existing bug found on the way: .toolbar has backdrop-filter,
making it a stacking context that trapped .run-mode-menu's z-index:1000, so
.welcome-overlay painted over the whole Run menu. With no session open, every
item in it (Claude Code included) was unclickable.
Verified end to end against a real tailnet dashboard: live data, WebSocket push,
no failed requests, and switching tabs does not reload the frame. 98 new tests
cover the pure rewrite helpers, the CORS helper, the shim's rewrite logic, route
CRUD, and every edge of the auth exemption.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7.8 KiB
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
- Click the chevron next to Run to expand the dropdown.
- Under Web / URL, pick Add dashboard...
- 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:
<base href>handles relative URLs in the markup.- Attribute rewriting handles root-absolute
src/href/action. - 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-permissionsagents, 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 |