fix(webview): let a proxied single-page app route on its own path, and recover a frame that reloads - #402
Open
shenlvkang-collab wants to merge 2 commits into
Open
Conversation
…other device An agent prints `http://localhost:5173/` (a dev server, a preview it just served) and the user taps it on a phone. That address only exists on the Codeman box, so the link was a guaranteed connection error from any other device — while the web-tab proxy fetches from the server, where it works. A loopback link (`localhost`, `*.localhost`, 127/8, 0.0.0.0, ::1) activated in the terminal or clicked in the Response Viewer now 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, with the link's own path, query and fragment opened inside it (a mounted frame is navigated, not torn down, so its state survives); otherwise one is saved under its host:port, sandboxed like any other web tab, so it is in the Run dropdown next time. Only loopback is routed this way. A LAN or tailnet address may well be reachable from the device (a VPN, the same Wi-Fi) and a direct open is the cheaper, richer path, so those keep opening in a new browser tab; on the box itself every link opens directly. The terminal link provider and the viewer's click handler consult one hook and fall through to their existing behaviour when it declines. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01McLWqCWBuQYGuPMScb4Aou
…d recover a frame that reloads
A dashboard served through a web tab saw `/webview/<cap>/` as its
`location.pathname`, and no app has a route for that: a React Router, Vue
Router or Vite dev-server page painted its HTML and CSS and then replaced
them with its own "page not found" the moment its script ran (reproduced
with a minimal history-routed page).
The proxy's runtime shim now rewrites the history entry to the path the
page would see on its own origin, before any page script runs. The base
element still resolves relative URLs inside the prefix and every root-
absolute sink is rewritten back into it, so only what the page READS
changes. With the document URL masked the Referer-keyed 404 rescue can no
longer help a request the shim misses, so the remaining URL-taking entry
points (`Worker`, `SharedWorker`, `navigator.sendBeacon`, `window.open`)
are covered by the shim as well.
A navigation the page starts itself afterwards — `location.reload()`
(a dev server's full-reload HMR), a root-absolute `location.href` — lands
on Codeman's root with no capability anywhere: no prefix in the path, no
cookie in an opaque-origin frame, a Referer naming the masked page. It is
recognised by shape (a top-level iframe navigation asking for HTML, for a
path Codeman does not serve) and answered with a static page whose only
script posts `{type:'codeman:webview-lost', path}` to the parent; the tab
that owns the frame (matched by `event.source`, never by the payload)
remounts it inside the prefix at that path, bounded per frame. The
unauthenticated form is answered in the auth middleware before the
credential checks, so a dev server that reloads on every save cannot
rate-limit its own user out of Codeman; the authenticated form (Basic
auth, trusted mode) is answered by the 404 handler.
Verified end to end against a history-routed page: boots on `/`, its
API call succeeds, a reload inside the frame comes back routed on the
path it had pushed, `location.href = '/about'` comes back on `/about`,
and a deep link opens on its path.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Stacked on #401 (its commit is the first in this branch; only the second commit is new). It fixes a bug that exists independently of #401: any single-page app opened as a web tab.
Symptom. A dashboard served through a web tab paints its HTML and CSS, then replaces them with its own "page not found" the moment its script runs. Reproduced with a minimal history-routed page: through the proxy,
location.pathnameis/webview/<cap>/, which no React Router / Vue Router / Vite dev-server app has a route for.Fix, two halves.
history.replaceState, which browsers allow inside the sandboxed frame — verified in Chromium). Only what the page reads changes:<base>still resolves relative URLs inside the prefix and every root-absolute sink is rewritten back into it. Because the masked document URL means the Referer-keyed 404 rescue can no longer help a request the shim misses, the remaining URL-taking entry points (Worker,SharedWorker,navigator.sendBeacon,window.open) are now covered by the shim as well.location.reload()(a dev server's full-reload HMR), a root-absolutelocation.href— lands on Codeman's root with no capability anywhere (no prefix, no cookie in an opaque-origin frame, a Referer naming the masked page). It is recognised by shape only (Sec-Fetch-Dest: iframe,Accept: text/html, a path Codeman does not serve) and answered with a static page whose only script posts{type:'codeman:webview-lost', path}to the parent. The owning tab matches the frame byevent.source(never by the payload), sanitises the path to a same-origin one, and remounts the frame inside the prefix at that path, bounded to 5 recoveries per minute per frame. The unauthenticated form is answered in the auth middleware before the credential checks, so a dev server that reloads on every save cannot rate-limit its own user out of Codeman; the authenticated form (Basic auth, trusted mode) is answered by the 404 handler. The page carriesdefault-src 'none'plus the hash of its one script, andreferrer: no-referrer.This also turns the documented "root-absolute
locationnavigation escapes the prefix" limit into a recovered case.Verified end to end (Playwright, sandboxed frame, a history-routed test page): boots on
/and itsfetch('/api/data')succeeds; a reload inside the frame comes back routed on the path it had pushed;location.href = '/about'comes back on/about; a deep link opens on its path; no 4xx on the wire.Docs:
docs/web-tabs.md(layers 5–6, Known limits). Changeset: patch.Test plan
test/webview-proxy.test.ts: masking (path/search/hash, landing page →/, non-prefixed document untouched), rewrites still applied after masking,window.openpatch;isLostWebviewFrameNavigationshape checks; the recovery page's script hash matches its CSP.test/webview-auth-exemption.test.ts: a lost frame gets the recovery page (200, HTML, CSP); never for/, a registered route, or a plain navigation; 20 recoveries do not trip the auth failure limit.test/webview-loopback-links.test.ts: remount at the lost path inside the prefix, bare reload → landing page, foreignevent.source/ malformed payload ignored,//host/xcannot jump the frame, loop bounded.test/webview-*set green;tsc --noEmit, eslint, prettier.🤖 Generated with Claude Code