Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/feat-loopback-links-web-tab.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 18 additions & 0 deletions .changeset/fix-webview-route-masking.md
Original file line number Diff line number Diff line change
@@ -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/<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.
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.
44 changes: 40 additions & 4 deletions docs/web-tabs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<cap>/` 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 `<iframe>` navigation asking for HTML, for a
path it does not serve) and answers a static page that does nothing but tell
the owning tab which path was lost; the tab remounts the frame inside the
prefix at that path. It never counts as a failed login, so a dev server that
reloads on every save cannot rate-limit its user out of Codeman.

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
Expand All @@ -141,10 +175,12 @@ then every API call fails, which looks like the dashboard being broken.
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).
- **Root-absolute `location` navigation is recovered, not prevented.** `Location`
is unforgeable, so `location.href = '/login'` or `location.reload()` really does
leave the prefix; the frame comes back through the recovery hop in layer 6 above,
which needs a browser that sends `Sec-Fetch-Dest` (every current one; iOS Safari
since 16.4). Older browsers show Codeman's 404 in the frame; the tab's **Reload**
button puts it back.
- **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
Expand Down
36 changes: 35 additions & 1 deletion src/web/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { findUser, setPassword, touchLastLogin, verifyPassword } from '../../user-store.js';
import { webviewCapabilities } from '../../webview-capabilities.js';
import { capabilityFromProxyPath, capabilityFromReferer } from '../webview-proxy.js';
import {
capabilityFromProxyPath,
capabilityFromReferer,
isLostWebviewFrameNavigation,
lostWebviewFramePage,
LOST_FRAME_PAGE_CSP,
} from '../webview-proxy.js';
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../../types.js';

// Request-scoped identity (multi-user). Single-user leaves it undefined and the
Expand Down Expand Up @@ -176,6 +182,30 @@ function hasValidWebviewCapability(req: FastifyRequest, basePath = ''): boolean
return !!fromReferer && webviewCapabilities.resolve(fromReferer) !== undefined;
}

/**
* A web-tab frame that navigated itself off the proxy prefix (see
* isLostWebviewFrameNavigation). It cannot authenticate: opaque origin, no cookie,
* no capability left in the URL. Answer with the static recovery page here, BEFORE
* the credential checks, so the reload of a proxied dashboard neither shows a
* login challenge inside the tab nor counts as a failed attempt against the
* caller's IP — a dev server that full-reloads on every save would otherwise
* rate-limit its own user out of Codeman. Fenced like the Referer exemption: a
* path that resolves to a real route (the app shell, /api, /q) is never answered
* this way, so a genuine unauthenticated navigation still gets the 401.
*
* @returns true when the reply was sent.
*/
function serveLostWebviewFrame(req: FastifyRequest, reply: FastifyReply): boolean {
if (!isLostWebviewFrameNavigation(req)) return false;
const url = (req.url ?? '').split('?')[0];
if (url === '/' || url.startsWith('/api/') || url.startsWith('/ws/') || url.startsWith('/q/')) return false;
if (matchesRegisteredRoute(req, url)) return false;
reply.header('content-security-policy', LOST_FRAME_PAGE_CSP);
reply.header('cache-control', 'no-store');
reply.type('text/html; charset=utf-8').send(lostWebviewFramePage());
return true;
}

/**
* Whether `url` resolves to a route Codeman actually registered.
*
Expand Down Expand Up @@ -302,6 +332,8 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
done();
return;
}
// A web-tab frame that lost its prefix: hand it back to its tab, no credentials involved.
if (serveLostWebviewFrame(req, reply)) return;

const clientIp = req.ip;

Expand Down Expand Up @@ -439,6 +471,8 @@ function registerMultiUserAuthHook(
// ownership against the identity BOUND TO THE CAPABILITY, which is stricter
// than re-deriving it from a request that carries no credentials.
if (hasValidWebviewCapability(req, basePath)) return;
// A web-tab frame that lost its prefix: hand it back to its tab, no credentials involved.
if (serveLostWebviewFrame(req, reply)) return;

const clientIp = req.ip;

Expand Down
10 changes: 10 additions & 0 deletions src/web/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2149,6 +2149,16 @@ class CodemanApp {
return;
}

// A `localhost` URL in the agent's answer: from another device that can
// only load through the server, so hand it to a proxied web tab
// (webview-tabs.js). Every other link keeps its new-tab default.
const urlLink = ev.target.closest('a[href]');
if (urlLink && this.openLinkThroughWebTabIfLoopback?.(urlLink.href)) {
ev.preventDefault();
ev.stopPropagation();
return;
}

// One-click copy: lift the raw source from the sibling <pre><code>.
const copyBtn = ev.target.closest('.rv-copy-btn');
if (copyBtn) {
Expand Down
4 changes: 4 additions & 0 deletions src/web/public/terminal-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -1448,6 +1448,10 @@ Object.assign(CodemanApp.prototype, {
range: { start, end },
decorations: { pointerCursor: true, underline: true },
activate(_event, text) {
// A `localhost` link tapped from another device can only work
// through the server: route it into a proxied web tab
// (webview-tabs.js). Anything else opens as before.
if (self.openLinkThroughWebTabIfLoopback?.(text)) return;
window.open(text, '_blank', 'noopener,noreferrer');
},
hover() {
Expand Down
Loading