diff --git a/.changeset/feat-loopback-links-web-tab.md b/.changeset/feat-loopback-links-web-tab.md new file mode 100644 index 000000000..6174dad21 --- /dev/null +++ b/.changeset/feat-loopback-links-web-tab.md @@ -0,0 +1,14 @@ +--- +"aicodeman": minor +--- + +feat(webview): open `localhost` links from the terminal and the Response Viewer through a proxied web tab + +An agent prints `http://localhost:5173/` 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. A loopback link (`localhost`, `*.localhost`, 127/8, 0.0.0.0, ::1) clicked in +the terminal or in the Response Viewer now opens as a proxied web tab whenever the +Codeman page itself is not on that box — reusing a saved proxied dashboard on the same +origin (with the link's own path opened inside it) or saving one under its host:port. +LAN and tailnet addresses, which the device may reach directly, keep opening in a new +browser tab, and on the box itself every link opens directly. diff --git a/.changeset/fix-webview-route-masking.md b/.changeset/fix-webview-route-masking.md new file mode 100644 index 000000000..6f542d7af --- /dev/null +++ b/.changeset/fix-webview-route-masking.md @@ -0,0 +1,18 @@ +--- +"aicodeman": patch +--- + +fix(webview): let a proxied single-page app route on its own path, and recover a frame that reloads + +A dashboard served through a web tab saw `/webview//` 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. +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, while every URL the page emits still goes through +the existing rewrite layers (plus `Worker`, `sendBeacon` and `window.open`, which the masked +Referer can no longer rescue). A navigation the page starts itself afterwards — a dev +server's full-reload HMR, a root-absolute `location.href` — lands on Codeman's root with no +capability; it is recognised by shape (an iframe navigation asking for HTML for a path Codeman +does not serve), answered with a static page that tells the owning tab which path was lost, +and the tab remounts the frame inside the prefix at that path. That answer is served before +the credential checks, so it never counts as a failed login. diff --git a/docs/web-tabs.md b/docs/web-tabs.md index 81f35f556..7f6be80e5 100644 --- a/docs/web-tabs.md +++ b/docs/web-tabs.md @@ -52,6 +52,25 @@ sandbox, cookies, CORS, CSP, or any reverse proxy sitting in front of Codeman, s 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`, `*.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); otherwise +one is saved under its `host:port` so it is in the Run dropdown next time. Sandboxed by +default, like any other web tab. + +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 @@ -128,6 +147,21 @@ layers cooperate so a dashboard talking to its own backend just works: 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. +5. The same script **masks the proxy prefix off the page's own URL** before any + of the page's code runs (`history.replaceState` to the path the page would see + on its own origin). A single-page app routes on `location.pathname` at boot, + and `/webview//` is a path no app has a route for: without this, a React + Router / Vue Router / Next dev server painted its HTML and CSS and then replaced + them with its own "page not found" the moment its script ran. The page only + *reads* the masked path; every URL it emits still goes through the layers above. +6. A navigation the page starts **itself** after that — `location.reload()` (a dev + server's full-reload HMR), a root-absolute `location.href = '/login'` — now + targets Codeman's root with no capability anywhere on it. Codeman recognises + that request by shape (a top-level `