chore: version packages

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-29 09:01:18 +02:00
parent 77bcbc9b94
commit a406aef2fa
16 changed files with 732 additions and 58 deletions
+1 -1
View File
@@ -117,7 +117,7 @@ The general rule: **any new endpoint that turns a caller-supplied `sessionId` in
**Two things a sandboxed frame breaks that are invisible to `curl`.** Both were found only by driving a real dashboard in a real browser, and both present identically as the dashboard's own "Failed to fetch" while the page itself renders fine:
1. **Root-absolute URLs built at runtime.** `<base href>` only governs URLs the HTML parser resolves; `fetch('/api/data')` bypasses it and lands on Codeman's root. That is how most dashboards talk to their own backend. The `Referer`-keyed 404 fallback deliberately refuses `/api`, `/ws`, `/q` (widening it there would let a request-supplied header skip auth on Codeman's own API), so the fix is `runtimeUrlShim()`: a small script injected right after `<base>` that patches `fetch`, `XMLHttpRequest.open`, `WebSocket` and `EventSource` to rebase root-absolute and same-origin-absolute URLs into the prefix. It removes the whole class inside the iframe instead of trading security for it. ⚠️ It must be injected even when the page ships its OWN `<base>` (an early return there silently breaks exactly the pages that need it most).
1. **Root-absolute URLs built at runtime.** `<base href>` only governs URLs the HTML parser resolves; `fetch('/api/data')` bypasses it and lands on Codeman's root. That is how most dashboards talk to their own backend. The `Referer`-keyed 404 fallback deliberately refuses `/api`, `/ws`, `/q` (widening it there would let a request-supplied header skip auth on Codeman's own API), so the fix is `runtimeUrlShim()`: a small script injected right after `<base>` that rebases root-absolute and same-origin-absolute URLs into the prefix. It removes the whole class inside the iframe instead of trading security for it. ⚠️ It must be injected even when the page ships its OWN `<base>` (an early return there silently breaks exactly the pages that need it most). ⚠️ **The DOM sinks are as load-bearing as `fetch`.** Patching only `fetch`/`XHR`/`WebSocket`/`EventSource` leaves `container.innerHTML = '<img src="/api/hero?slug=x">'` and `img.src = '/api/slide'` untouched, and neither of the other layers can reach those either (`<base>` never applies to root-absolute URLs, and `rewriteHtml()` only ever sees the INITIAL document, never markup built later by page script). The symptom is precise and easy to misdiagnose as an upstream fault: the dashboard's **data** loads while every **image** stays broken. So the shim also wraps `innerHTML`/`outerHTML`/`insertAdjacentHTML`, `setAttribute`/`setAttributeNS`, and the `src`/`srcset`/`href`/`poster`/`data`/`action` property setters, with a `MutationObserver` as a last net for sinks not patched above. Every rewrite routes through the same idempotent `rw()`, which matters because unlike the server-side rewrite this one sees markup that may ALREADY be proxied (a page re-injecting its own `outerHTML` would otherwise double-prefix). The DOM half is pinned in jsdom by `test/webview-proxy.test.ts`; `curl` cannot see any of it.
2. **CORS on same-host requests.** An opaque-origin document treats EVERY request as cross-origin, including to the very host it was served from, so its `fetch`/XHR are CORS-checked and its preflights carry `Origin: null`. Static subresources (script/css/img) are NOT CORS-checked, which is why the page renders while its API calls die with an opaque `net::ERR_FAILED`. `buildProxyCorsHeaders()` echoes the origin (omitting `allow-credentials` for `null`, which browsers reject in combination), upstream `access-control-*` headers are dropped (they describe the dashboard's origin, not the frame's), and the proxy answers preflights itself rather than relaying them. ⚠️ `registerSecurityHeaders` answers EVERY `OPTIONS` with a bare 204 before routing, and its CORS block only emits headers for localhost origins, so that short-circuit **must** exempt a valid webview capability or every preflight fails. `curl` cannot reproduce any of this because curl does not enforce CORS.
**Rewrites, each load-bearing** (pure + unit-tested in `src/web/webview-proxy.ts`): drop `x-frame-options` and the CSP `frame-ancestors` directive (the point of the proxy); drop `content-encoding`/`content-length` because undici's `fetch` already decoded the body (forwarding them makes the browser gunzip plaintext); rewrite `Location` for same-origin redirects only, handing CROSS-origin redirects back unchanged so this never becomes an open relay; rebase `Set-Cookie` `Path` onto the prefix and drop `Domain`; inject `<base href>` and rebase root-absolute `src`/`href`/`action`. `resolveUpstreamUrl()` returns null on anything escaping the upstream origin.
+151
View File
@@ -0,0 +1,151 @@
# Web tabs: two fixes (planned + implemented 2026-07-28)
Both found against the saved dashboard
`https://macminis-mac-mini.tailf80371.ts.net:4000` (Bio-Hacking-Dashboard).
Kept because the root-cause analysis of the second one is not obvious from the
resulting diff.
Status: **both implemented and verified end-to-end.** The one deliberate
non-change is recorded at the bottom.
---
## Bug 1: saved URLs could not be deleted from the Run dropdown
### What happened
The "Web / URL" section of the Run dropdown listed every saved dashboard as a
single clickable row whose only action was "open". Deleting required opening the
dashboard as a tab, clicking the tab's gear, then Delete in the modal, so a URL
you no longer wanted open at all could not be removed without first opening it.
### What shipped
- `renderWebviewMenuItems()` (`src/web/public/webview-tabs.js`) now renders each
saved URL as a `.run-mode-row--web` flex row: the open button, a gear
(`showWebviewModal`), and an `x` (`deleteWebviewById`). Nested buttons are
invalid HTML, hence the wrapper rather than a button inside a button.
- `deleteWebview()` split into the modal entry point, the new row entry point
`deleteWebviewById(id)`, and the shared `_confirmAndDeleteWebview(id)`.
- Both side buttons call `event.stopPropagation()` so the click does not also
open the dashboard.
- The dropdown's outside-click handler (`session-ui.js`) closes when the click
target is not inside `#runModeMenu`, and the row is gone by the time the delete
resolves, so `deleteWebviewById` re-asserts `.active` on the menu. Verified in a
browser: deleting one of several URLs leaves you looking at the rest of the list.
- CSS in `styles.css` (`.run-mode-row--web`, `.run-mode-row-btn`) plus a larger
touch target in `mobile.css`. The side buttons are permanently visible rather
than hover-revealed, because this menu is used on touch.
No server change: `DELETE /api/webviews/:id` already existed, owner-scoped, and
already revoked the capability and broadcast `WebviewChanged`.
---
## Bug 2: images did not load in a proxied dashboard
### Reproduction (before the fix)
```
CAP=<from POST /api/webviews/<id>/open>
# A) upstream direct -> 200 image/jpeg 118150
curl -sk "https://macminis-mac-mini.tailf80371.ts.net:4000/api/hero?slug=120-minutes-in-nature"
# B) through the proxy prefix -> 200 image/jpeg 118150
curl -sk "https://localhost:3000/webview/$CAP/api/hero?slug=120-minutes-in-nature"
# C) what the browser ACTUALLY requested -> 404 {"errorCode":"NOT_FOUND"}
curl -sk -H "Referer: https://localhost:3000/webview/$CAP/" \
"https://localhost:3000/api/hero?slug=120-minutes-in-nature"
# D) same shape but NOT under /api -> 200 (referer fallback rescues it)
curl -sk -H "Referer: https://localhost:3000/webview/$CAP/" "https://localhost:3000/styles.css"
```
The proxy itself was fine (B). The failure was entirely about which URL the
browser ended up requesting (C).
### Root cause
The dashboard builds its image markup at runtime with root-absolute URLs:
`c.innerHTML = '<img class="thumb" src="/api/hero?slug=...">'`, `img.src =
slideSrc(...)` returning `/api/slide?owner=...`, `/api/story`, `/api/video`, and a
nested `<iframe src="/api/preview?slug=...">`.
All three rewrite layers missed that shape:
1. `<base href="/webview/<cap>/">` only affects **relative** URLs. A root-absolute
`/api/hero` ignores the base path and resolves against Codeman's origin.
2. `rewriteHtml()` only runs over the **initial HTML document**. This markup is
created later by page script. (The static header `<img src="/api/logo">` DID
work, having been rewritten at proxy time, which is why only the
runtime-injected images were broken.)
3. `runtimeUrlShim()` patched only `fetch`, `XMLHttpRequest.open`, `WebSocket` and
`EventSource`, so the dashboard's **data** loaded while its **pictures** did
not.
The safety net was fenced off from `/api` in two places, both deliberate:
`server.ts`'s not-found handler returns the API-envelope 404 before reaching
`tryWebviewRefererFallback`, and `middleware/auth.ts` refuses the Referer-form
auth exemption for `/api/`, `/ws/`, `/q/`.
### What shipped
`runtimeUrlShim()` in `src/web/webview-proxy.ts` now also covers the DOM sinks, so
a root-absolute `/api/...` request is never emitted in the first place and neither
security fence had to move:
- `innerHTML` / `outerHTML` / `insertAdjacentHTML` (and `ShadowRoot.innerHTML`),
- `setAttribute` / `setAttributeNS`,
- the `src`/`srcset`/`href`/`poster`/`data`/`action` property setters on img,
source, media, video poster, script, iframe, embed, track, link, anchor, area,
object and form,
- a `MutationObserver` as a last net for any sink not patched above (it costs one
wasted 404 per node, since the browser starts fetching on insert, so it is a net
and not the mechanism).
Two details that mattered:
- Every rewrite routes through the existing idempotent `rw()` rather than a blind
prefix concat. The first draft used the server-side regex shape and
double-prefixed markup that was already proxied (a page re-injecting its own
`outerHTML`); the jsdom test caught it.
- Everything stays inside `try`/`catch` and is marked `__cmrw`, so a double
injection cannot wrap an already-wrapped setter, and nothing can throw into a
page we do not control.
### Verification
- `test/webview-proxy.test.ts` gained a jsdom `runtimeUrlShim DOM sinks` block:
innerHTML, insertAdjacentHTML, property setters, setAttribute, srcset candidate
lists, the MutationObserver net via an unpatched sink
(`createContextualFragment`), idempotence, re-injected markup, empty `src`, and
the pass-throughs (relative, cross-origin, `#hash`, `data:`). 73 tests pass.
- End-to-end in a real browser against an isolated instance
(`CODEMAN_INSTANCE=wvtest`, port 3151), with prod's old build as the negative
control:
| | before (prod, old build) | after (fixed) |
| --- | --- | --- |
| images found | 693 | 693 |
| src under the proxy prefix | 0 | 693 |
| in-viewport images decoded | 0 / 23 | 23 / 23 |
| sample src | `/api/hero?slug=...` | `/webview/<cap>/api/hero?slug=...` |
(The dashboard marks thumbs `loading="lazy"`, so only in-viewport images are
ever fetched. All 27 proxied image responses returned 200.)
---
## Deliberately NOT done: widening the `/api` referer fallback
Considered as defense in depth, and skipped. It would have to change **both**
gates or it is useless on a password-protected instance, and the auth gate is the
security-sensitive one: auth runs in `onRequest`, before routing, so it cannot
tell a real Codeman API route from a 404. Dropping the `/api` fence would let a
page holding a capability forge a `Referer` and reach Codeman's **real** API
unauthenticated. Doing it safely means exempting only paths that match no
registered route (via Fastify's `hasRoute`/`findRoute`), which is a separate
change deserving its own review and its own cases in
`test/webview-auth-exemption.test.ts`.
The remaining gap this leaves is narrow and documented in `docs/web-tabs.md`: a
root-absolute `url(/img.png)` inside a stylesheet injected at runtime. Non-`/api`
ones are already rescued by the existing referer fallback.
+21 -6
View File
@@ -15,7 +15,9 @@ 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.
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.
@@ -97,9 +99,15 @@ Worth knowing, because it is where this feature does its least obvious work. Thr
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.
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">'` and `img.src = '/api/slide'`. 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.
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
@@ -110,8 +118,15 @@ 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.
EventSource, normal markup, and the DOM sinks a page uses to build markup at
runtime. Something that constructs requests by an unusual route can still slip
through. Symptom: the page renders but a panel stays empty. The known remaining
gap is a root-absolute `url(/img.png)` inside a stylesheet the page injects at
runtime; one under `/api` has no fallback and will 404.
- **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