mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
A Fable 5.1 reviewer read the whole release diff against 1.26.2 and returned
SHIP WITH FIXES. These are its findings, verified before acting on each.
**The changelog advertised a feature the code refuses (major).** The #401
changeset and docs/web-tabs.md both listed `*.localhost` in the loopback set.
The follow-up in 02b0e278 moved it out of the auto-route set on security
grounds and updated CLAUDE.md but neither of those, and that changeset becomes
the 1.27.0 CHANGELOG entry: a user would have read the release notes, tapped
`http://app.localhost:3000/` on a phone and got a connection error from a
documented feature. Both corrected, and the user guide now says why it is
excluded and that adding such a dashboard by hand still works.
**Dictation delivered its text twice (minor, #388).** `keydownSnapshot` started
`null`, so `keydownSnapshot ?? canonicalCount` at the input event read a counter
xterm had ALREADY bumped: on a fresh page load with no keydown yet, xterm's own
capture listener forwards the `insertText` itself (it is not gated behind a
keydown), then the snapshot equals the bumped count, `count > snapshot` is
false, and the controller emits the same text again. Reproduced directly
against the module: it emitted `hello` for input xterm had already delivered.
A `0` baseline restores that file's own invariant, that a missed recovery is
acceptable and a duplicated keystroke is not. Two regression tests, covering
both the xterm-already-delivered and genuinely-dropped halves.
**The sorted rail's arrow-key walk followed the DOM (minor).** `_tabKeydownHandler`
steps `querySelectorAll` order, which is `sessionOrder`, while a sorted rail
paints its rows with the flex `order` property, so ArrowDown from the top card
landed wherever that session happened to sit in the tab order. It now sorts its
node list by the COMPUTED order first: computed rather than inline, because web
tabs take their `order: 9999` from CSS and would otherwise read as 0 and lead
the walk. This is the one place that follows the paint; the Alt+N badge, the
drag model and the filter all still deliberately read the DOM.
**A trusted dashboard was auto-reused by a tapped link (minor, #401).** The
reuse loop skipped `managed` and direct-mode records but not `trusted`. A
trusted frame is mounted with `allow-same-origin`, i.e. on Codeman's origin
with the user's cookie, and these links come from agent output, which is the
threat model the loopback allowlist was just narrowed for. An agent that can
write into the dev server's tree could print a path that one tap opens inside
that privileged frame. Excluded from auto-reuse, with a test; opening it from
the Run dropdown is still an explicit action and unchanged.
**Two documentation claims that were no longer true.** CLAUDE.md said
test/location-overlay-commands.test.ts pins every remote pane command, but
remote claude and remote omp now have their own arm in `buildRemoteLaunchCommand`
and never reach `defaultRemoteCommandForMode`, which is what that test asserts,
so it pins nothing for them and changing either arm will not fail it. Named the
real pins instead. Also documented the arrow-key-walk exception in the rail
paragraph.
Left as follow-ups, deliberately: `POST /api/webviews` does not dedupe by URL
server-side, so two devices tapping one link concurrently can still save two
dashboards for one origin (pre-existing endpoint behaviour that #401 makes
reachable by a tap), and the location-overlay golden should assert the real
remote claude/omp commands rather than a branch neither reaches.
Full gate green: 359 files, 6869 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
222 lines
14 KiB
Markdown
222 lines
14 KiB
Markdown
# 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/Antigravity 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.
|
|
To delete it for good, use the `x` on its **dropdown row** (the tab's own `x` is
|
|
close, not delete). Each dropdown row also has a gear for editing, so a saved URL
|
|
can be changed or removed without opening it first.
|
|
|
|
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. Note what Test actually verifies:
|
|
**server-to-upstream reachability, nothing else**. It does not exercise the browser
|
|
sandbox, cookies, CORS, CSP, or any reverse proxy sitting in front of Codeman, so a
|
|
passing Test does not guarantee the embedded page will render (see the
|
|
cookie-authenticated reverse proxy caveat below).
|
|
|
|
## Links to `localhost` from another device
|
|
|
|
An agent prints `http://localhost:5173/` (a dev server, a preview, a report it just
|
|
served) and you tap it on your phone. That address only exists on the Codeman box, so
|
|
the phone's browser can never load it — but the web-tab proxy fetches from the server,
|
|
where it works.
|
|
|
|
So a **loopback** link (`localhost`, `127.0.0.0/8`, `0.0.0.0`, `::1`) clicked
|
|
in the terminal or in the Response Viewer opens as a **proxied web tab** whenever the
|
|
Codeman page itself is not on that box. A saved proxied dashboard on the same origin is
|
|
reused (one tab per dev server, with the link's own path opened inside it, and one tab
|
|
per dev server rather than per host spelling, so `localhost:5173` and `127.0.0.1:5173`
|
|
share it); otherwise one is saved under its `host:port` so it is in the Run dropdown
|
|
next time, and a toast tells you it was saved. Sandboxed by default, like any other web
|
|
tab.
|
|
|
|
⚠️ **`*.localhost` is deliberately not auto-routed**, even though a browser treats it as
|
|
loopback. Every other name in that list is an address literal that can only mean this
|
|
box; a `*.localhost` DNS name is not one, and on a resolver with a search domain
|
|
configured `evil.localhost` can be retried as `evil.localhost.<search domain>`, which
|
|
someone else can control. Since the links come from agent output, one tap would then
|
|
make Codeman fetch an agent-chosen origin server-side and save it. If you really run
|
|
`api.localhost` dev hosts, add that dashboard by hand: doing so is an explicit action,
|
|
which is the difference that matters here. A **trusted** (non-sandboxed) dashboard is
|
|
likewise never auto-reused by a tapped link, for the same reason.
|
|
|
|
Only loopback is routed this way. A LAN or tailnet address (`192.168.…`, `100.…`,
|
|
`box.ts.net`) may well be reachable from the device — a VPN, the same Wi-Fi — and a
|
|
direct open is the cheaper, richer path, so those links still open in a new browser tab.
|
|
On the box itself (a browser on `localhost`) every link opens directly.
|
|
|
|
## 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.
|
|
|
|
⚠️ **Sandboxed tabs may not work when Codeman itself is behind a
|
|
cookie-authenticated reverse proxy** (Cloudflare Access, Authelia, oauth2-proxy and
|
|
similar). The sandboxed frame is opaque-origin, so its stylesheet, script, and API
|
|
requests do not carry the proxy's authentication cookie; the proxy redirects them to
|
|
the login provider, where CORS/CSP kills them, and the embedded app renders
|
|
unstyled or broken while the Codeman page around it works fine. Trusted mode
|
|
(**Open sandboxed** off) keeps a real origin and the cookie, so it works. The
|
|
**Test** button cannot catch this: it checks that the Codeman *server* can reach the
|
|
upstream, not that a sandboxed *browser* frame can load assets through the public
|
|
authentication layer.
|
|
|
|
## 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` in the page the
|
|
proxy serves.
|
|
3. A small injected script rebases URLs built at **runtime**, which the first two
|
|
cannot see: `fetch('/api/data')` and `new WebSocket('/live')`, but equally
|
|
`card.innerHTML = '<img src="/api/hero">'`, `img.src = '/api/slide'`, and
|
|
`url(/img.png)` inside a `<style>` the page injects. That second group is why
|
|
images are covered too. A dashboard that renders its thumbnails from script
|
|
would otherwise show all its data and none of its pictures, because `<base>`
|
|
does not apply to root-absolute URLs and the attribute rewriting only ever saw
|
|
the initial document.
|
|
4. As a last resort, a request that still lands on Codeman's own root is relayed
|
|
using its `Referer` to identify the dashboard. This only fires for a request
|
|
that already missed every Codeman route, and never for one that resolves to a
|
|
real route, which is what keeps it from being an authentication bypass.
|
|
|
|
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 layers above cover normal `fetch`/XHR/WebSocket/
|
|
EventSource, normal markup, the DOM sinks a page uses to build markup at runtime,
|
|
and `url()` inside stylesheets. Something that constructs requests by an unusual
|
|
route can still slip through. Symptom: the page renders but a panel stays empty.
|
|
- **Root-absolute `location` navigation.** A dashboard that navigates itself with
|
|
`location.href = '/login'` escapes the prefix, because `Location.href` is
|
|
unforgeable and cannot be patched the way the other sinks are. A relative
|
|
`location.href = 'login'` is fine (`<base>` covers it).
|
|
- **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.
|
|
- **Cookie-authenticated reverse proxies in front of Codeman break sandboxed tabs**
|
|
(#238). The sandboxed frame's requests carry no auth cookie, so the proxy bounces
|
|
them to its login provider and the app loads broken while Test reports reachable.
|
|
Use trusted mode behind Cloudflare Access and friends; see the warning above.
|
|
- **Slow endpoints and the upstream timeout** (#237). The proxy waits
|
|
`CODEMAN_WEBVIEW_TIMEOUT_MS` (default 300s) for the upstream's response *headers*,
|
|
then streams the body without any time bound; a header timeout is logged
|
|
server-side and answered as a 502 that names the limit. WebSocket handshakes use
|
|
the separate `CODEMAN_WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS` (default 30s).
|
|
- **Not a security boundary, with one carve-out.** The proxy reaches whatever the
|
|
Codeman server can reach (a `localhost` dashboard is the point), so it 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. The carve-out: link-local and
|
|
cloud-metadata addresses (`169.254.0.0/16`, `fe80::/10`, `fd00:ec2::254`,
|
|
Azure's `168.63.129.16`, Alibaba's `100.100.100.200`, the
|
|
`metadata.google.internal` alias) are refused at save time AND at connect
|
|
time, judged on the address a name actually resolves to. Nothing anyone embeds
|
|
as a dashboard lives there; an instance's IAM credentials do.
|
|
- **The proxy URL is a bearer credential.** `/webview/<cap>/...` needs no cookie,
|
|
so treat it like a password. It is revoked when you log out, when an admin logs
|
|
you out, and when your account is deleted, and it expires after 12 hours
|
|
without use. Proxied responses carry `Referrer-Policy: same-origin`, so a
|
|
dashboard that links to third-party sites does not hand them the URL.
|
|
|
|
## 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` |
|