feat(web-tabs): open dashboard URLs as tabs beside agent sessions

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>
This commit is contained in:
Codeman maintainer
2026-07-27 17:06:36 +02:00
parent ea4c935d51
commit b34fcaf928
26 changed files with 3316 additions and 18 deletions
+64
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">
@@ -464,6 +469,14 @@
<span class="run-mode-dot shell"></span>Terminal / Shell
</button>
<div class="run-mode-sep"></div>
<!-- Web tabs: dashboards open as tabs beside agent sessions. These do NOT
set runMode: the Run button always means "start an agent". -->
<div class="run-mode-header">Web / URL</div>
<div class="run-mode-webviews" id="runModeWebviews"></div>
<button class="run-mode-option run-mode-option--add" onclick="app.showWebviewModal()">
<span class="run-mode-dot web"></span>Add URL&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>
@@ -639,6 +652,56 @@
</div>
</div>
<!-- Web Tab (dashboard URL) editor -->
<div class="modal" id="webviewModal">
<div class="modal-backdrop" onclick="app.closeWebviewModal()"></div>
<div class="modal-content">
<div class="modal-header">
<h3 id="webviewModalTitle">Add URL</h3>
<button class="modal-close" onclick="app.closeWebviewModal()" aria-label="Close URL editor">&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>
@@ -2551,6 +2614,7 @@
<script defer src="ultracode-panel.js"></script>
<script defer src="admin-ui.js"></script>
<script defer src="session-ui.js"></script>
<script defer src="webview-tabs.js"></script>
<script defer src="ralph-wizard.js"></script>
<script defer src="api-client.js"></script>
<script defer src="subagent-windows.js"></script>