From d9eeb039db9adfa31c73f04665707e6ace1f3263 Mon Sep 17 00:00:00 2001 From: shenlvkang-collab Date: Thu, 10 Sep 2026 12:48:47 +0800 Subject: [PATCH 1/2] feat(webview): open localhost links through a proxied web tab from another device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01McLWqCWBuQYGuPMScb4Aou --- .changeset/feat-loopback-links-web-tab.md | 14 ++ docs/web-tabs.md | 19 ++ src/web/public/app.js | 10 + src/web/public/terminal-ui.js | 4 + src/web/public/webview-tabs.js | 134 ++++++++++++- test/webview-loopback-links.test.ts | 228 ++++++++++++++++++++++ 6 files changed, 405 insertions(+), 4 deletions(-) create mode 100644 .changeset/feat-loopback-links-web-tab.md create mode 100644 test/webview-loopback-links.test.ts 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/docs/web-tabs.md b/docs/web-tabs.md index 81f35f556..753a607dd 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 diff --git a/src/web/public/app.js b/src/web/public/app.js index 17b0d5d93..bdc56aee6 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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
.
       const copyBtn = ev.target.closest('.rv-copy-btn');
       if (copyBtn) {
diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js
index 734b9610c..6a26f401f 100644
--- a/src/web/public/terminal-ui.js
+++ b/src/web/public/terminal-ui.js
@@ -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() {
diff --git a/src/web/public/webview-tabs.js b/src/web/public/webview-tabs.js
index 0e56365b9..d17ae5820 100644
--- a/src/web/public/webview-tabs.js
+++ b/src/web/public/webview-tabs.js
@@ -19,7 +19,114 @@
  * @loadorder 12.5 of 16, after session-ui.js (needs the tab strip), before api-client.js
  */
 
+// ── Loopback links ──────────────────────────────────────────────────────────
+//
+// An agent prints `http://localhost:5173/` and the user taps it on a phone.
+// That link can only ever resolve on the Codeman box itself, so opening it in
+// the browser is a guaranteed connection error from anywhere else — while the
+// proxied web tab fetches from the server, where it works. 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.
+
+const LOOPBACK_HOSTNAMES = new Set(['localhost', '0.0.0.0', '::1', '[::1]', '::', '[::]']);
+
+/** `localhost`, `*.localhost`, 127.0.0.0/8, 0.0.0.0 and the IPv6 loopback forms. */
+function isLoopbackHostname(hostname) {
+  const host = String(hostname || '')
+    .trim()
+    .toLowerCase()
+    .replace(/\.$/, '');
+  if (!host) return false;
+  if (LOOPBACK_HOSTNAMES.has(host) || host.endsWith('.localhost')) return true;
+  const ipv4 = /^(\d{1,3})\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.exec(host);
+  return !!ipv4 && Number(ipv4[1]) === 127;
+}
+
+/**
+ * Whether a link should open through a proxied web tab rather than directly:
+ * an http(s) URL on a loopback host, viewed from a page that is NOT itself on
+ * that host (on the box, the browser can reach localhost and the direct open
+ * keeps devtools, extensions and the real origin).
+ */
+function linkNeedsWebTabProxy(rawUrl, pageHostname) {
+  let url;
+  try {
+    url = new URL(String(rawUrl || ''));
+  } catch {
+    return false;
+  }
+  if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
+  if (!isLoopbackHostname(url.hostname)) return false;
+  return !isLoopbackHostname(pageHostname);
+}
+
+if (typeof window !== 'undefined') {
+  window.CodemanWebviewLinks = { isLoopbackHostname, linkNeedsWebTabProxy };
+}
+
 Object.assign(CodemanApp.prototype, {
+  // ── Loopback links ────────────────────────────────────────────────────────
+
+  /**
+   * Take a link the device cannot reach and open it through a proxied web tab.
+   * Returns true when it took the link; false leaves the caller's own opening
+   * path (window.open, an anchor's default) untouched.
+   */
+  openLinkThroughWebTabIfLoopback(rawUrl) {
+    if (!linkNeedsWebTabProxy(rawUrl, window.location?.hostname)) return false;
+    void this.openUrlInWebTab(rawUrl);
+    return true;
+  },
+
+  /**
+   * Open an arbitrary URL as a proxied web tab, deep path included. A saved
+   * proxied dashboard on the same origin is reused (one tab per dev server,
+   * not one per link); otherwise one is saved under the host:port name so it
+   * is there in the Run dropdown next time.
+   */
+  async openUrlInWebTab(rawUrl) {
+    let url;
+    try {
+      url = new URL(String(rawUrl || ''));
+    } catch {
+      return;
+    }
+    if (!this.webviews) await this.refreshWebviews();
+    if (!this.webviews) {
+      this.showToast?.('Could not open URL', 'error');
+      return;
+    }
+
+    let existing = null;
+    for (const webview of this.webviews.values()) {
+      if (webview.managed || (webview.embedMode ?? 'proxy') !== 'proxy') continue;
+      try {
+        if (new URL(webview.url).origin === url.origin) {
+          existing = webview;
+          break;
+        }
+      } catch {
+        /* a saved URL that no longer parses is not a match */
+      }
+    }
+
+    let id = existing?.id;
+    if (!id) {
+      const created = await this._apiJson('/api/webviews', {
+        method: 'POST',
+        body: { name: url.host.slice(0, 60), url: `${url.origin}/`, embedMode: 'proxy', trusted: false },
+      });
+      if (!created?.id) {
+        this.showToast?.('Could not open URL', 'error');
+        return;
+      }
+      await this.refreshWebviews();
+      id = created.id;
+    }
+    const path = `${url.pathname}${url.search}${url.hash}`;
+    await this.openWebview(id, { path: path === '/' ? '' : path });
+  },
+
   // ── State ─────────────────────────────────────────────────────────────────
 
   /** Load the saved list and restore which tabs were open. */
@@ -133,7 +240,14 @@ Object.assign(CodemanApp.prototype, {
    * memory-only and expire, so a tab reopened after a server restart must not reuse
    * the dead URL from the previous run.
    */
-  async openWebview(id) {
+  /**
+   * @param {string} id
+   * @param {{path?: string}} [options] `path` (pathname+search+hash) opens a
+   *   deep link inside the dashboard: appended to the proxy prefix, or resolved
+   *   against the real URL in direct mode. A mounted frame is navigated there
+   *   rather than left on whatever page it was showing.
+   */
+  async openWebview(id, options = {}) {
     const webview = this.webviews.get(id);
     if (!webview) return;
 
@@ -149,8 +263,14 @@ Object.assign(CodemanApp.prototype, {
     }
     if (data.webview) this.webviews.set(id, data.webview);
 
-    const src = data.embedUrl || data.webview?.url || webview.url;
-    this._mountWebviewFrame(id, src, data.webview || webview);
+    let src = data.embedUrl || data.webview?.url || webview.url;
+    const path = typeof options.path === 'string' ? options.path : '';
+    if (path) {
+      // The proxy prefix is `/webview//`; a wildcard rides after it. In
+      // direct mode the deep link resolves against the dashboard's own origin.
+      src = data.embedUrl ? `${data.embedUrl.replace(/\/?$/, '/')}${path.replace(/^\//, '')}` : new URL(path, src).href;
+    }
+    this._mountWebviewFrame(id, src, data.webview || webview, { navigate: !!path });
     this.activeWebviewId = id;
     this.hideWelcome?.();
     document.querySelector('.main')?.classList.add('webview-active');
@@ -162,11 +282,17 @@ Object.assign(CodemanApp.prototype, {
   },
 
   /** Create the frame if absent, then reveal it and hide its siblings. */
-  _mountWebviewFrame(id, src, webview) {
+  _mountWebviewFrame(id, src, webview, { navigate = false } = {}) {
     const layer = document.getElementById('webviewLayer');
     if (!layer) return;
 
     let wrap = layer.querySelector(`.webview-frame[data-webview-id="${CSS.escape(id)}"]`);
+    if (wrap && navigate) {
+      // A deep link into an already-mounted dashboard: navigate the live frame
+      // instead of tearing it down, so its login and state survive.
+      const frame = wrap.querySelector('iframe');
+      if (frame) frame.src = CodemanBase.url(src);
+    }
     if (!wrap) {
       wrap = document.createElement('div');
       wrap.className = 'webview-frame';
diff --git a/test/webview-loopback-links.test.ts b/test/webview-loopback-links.test.ts
new file mode 100644
index 000000000..6c1376e4a
--- /dev/null
+++ b/test/webview-loopback-links.test.ts
@@ -0,0 +1,228 @@
+/**
+ * @fileoverview Loopback links open through a proxied web tab (webview-tabs.js).
+ *
+ * An agent prints `http://localhost:5173/` and the user taps it on a phone. The
+ * browser there can never reach the Codeman box's loopback, so the link was a
+ * guaranteed connection error — while the web-tab proxy fetches from the server,
+ * where it works. Pinned here:
+ *
+ *  1. The decision: only http(s) on a loopback host, and only when the page
+ *     itself is not on that host. A LAN/tailnet address stays a direct open.
+ *  2. A saved proxied dashboard on the same origin is reused, with the deep
+ *     path appended to the minted proxy prefix; a mounted frame is navigated,
+ *     not torn down.
+ *  3. An unknown origin is saved under its host:port and opened.
+ *  4. The terminal link provider and the response viewer consult the hook
+ *     before their own opening path.
+ *
+ * Same in-test JSDOM boot as webview-menu-rows.test.ts (no per-file env).
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { JSDOM } from 'jsdom';
+
+const CONSTANTS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8');
+const WEBVIEW_TABS = readFileSync(new URL('../src/web/public/webview-tabs.js', import.meta.url), 'utf-8');
+const TERMINAL_UI = readFileSync(new URL('../src/web/public/terminal-ui.js', import.meta.url), 'utf-8');
+const APP_JS = readFileSync(new URL('../src/web/public/app.js', import.meta.url), 'utf-8');
+
+type Webview = { id: string; name: string; url: string; embedMode?: string; managed?: string };
+
+interface AppLike {
+  webviews: Map;
+  webviewOrder: string[];
+  activeWebviewId: string | null;
+  renderSessionTabs(): void;
+  refreshWebviews(): Promise;
+  openLinkThroughWebTabIfLoopback(url: string): boolean;
+  openUrlInWebTab(url: string): Promise;
+  openWebview(id: string, options?: { path?: string }): Promise;
+  _apiJson(path: string, opts?: { method?: string; body?: unknown }): Promise;
+  _updateActiveWebviewTab(): void;
+  showToast?: (msg: string, kind: string) => void;
+}
+
+function boot(pageUrl = 'http://192.168.1.135:8095/') {
+  const dom = new JSDOM(
+    `
`, + { url: pageUrl, runScripts: 'outside-only' } + ); + const win = dom.window as unknown as Window & + typeof globalThis & { app: AppLike; CodemanApp: new () => AppLike; CodemanWebviewLinks: WebviewLinks }; + (win as unknown as { eval: (s: string) => void }).eval( + [ + 'window.CodemanApp = class CodemanApp {};', + 'window.CodemanBase = { url: (p) => p };', + 'if (!window.CSS) window.CSS = { escape: (s) => s };', + 'window.requestAnimationFrame = (fn) => { fn(); return 1; };', + CONSTANTS, + WEBVIEW_TABS, + ].join('\n') + ); + + const calls: Array<{ path: string; method: string; body?: unknown }> = []; + const app = new win.CodemanApp(); + app.webviews = new Map([ + ['dev', { id: 'dev', name: 'localhost:5173', url: 'http://localhost:5173/' }], + ['direct', { id: 'direct', name: 'Direct', url: 'https://localhost:9443/', embedMode: 'direct' }], + ]); + app.webviewOrder = []; + app.activeWebviewId = null; + app.renderSessionTabs = () => {}; + app._updateActiveWebviewTab = () => {}; + app.refreshWebviews = async () => {}; + app._apiJson = async (path: string, opts: { method?: string; body?: unknown } = {}) => { + calls.push({ path, method: opts.method || 'GET', body: opts.body }); + if (path === '/api/webviews' && opts.method === 'POST') { + const body = opts.body as { name: string; url: string }; + const created = { id: 'new-id', name: body.name, url: body.url, embedMode: 'proxy' }; + app.webviews.set(created.id, created); + return created; + } + const open = /^\/api\/webviews\/([^/]+)\/open$/.exec(path); + if (open) { + const id = decodeURIComponent(open[1]); + const webview = app.webviews.get(id); + if (!webview) return null; + return webview.embedMode === 'direct' ? { webview } : { webview, embedUrl: `/webview/cap-${id}/` }; + } + return null; + }; + win.app = app; + return { win, app, calls }; +} + +interface WebviewLinks { + isLoopbackHostname(host: string): boolean; + linkNeedsWebTabProxy(url: string, pageHostname: string): boolean; +} + +const frameSrc = (win: Window, id: string) => + ( + win.document.querySelector(`.webview-frame[data-webview-id="${id}"] iframe`) as HTMLIFrameElement | null + )?.getAttribute('src'); + +describe('loopback link decision', () => { + const { win } = boot(); + const links = win.CodemanWebviewLinks; + + it('recognises every loopback spelling and nothing else', () => { + for (const host of [ + 'localhost', + 'LOCALHOST', + 'app.localhost', + '127.0.0.1', + '127.1.2.3', + '0.0.0.0', + '[::1]', + '::1', + ]) { + expect(links.isLoopbackHostname(host), host).toBe(true); + } + for (const host of [ + '192.168.1.135', + '10.9.0.4', + '172.16.0.2', + 'box.ts.net', + '128.0.0.1', + '', + 'localhost.example.com', + ]) { + expect(links.isLoopbackHostname(host), host).toBe(false); + } + }); + + it('proxies a loopback http(s) link only when the page is not on that box', () => { + expect(links.linkNeedsWebTabProxy('http://localhost:5173/', '192.168.1.135')).toBe(true); + expect(links.linkNeedsWebTabProxy('https://127.0.0.1:8443/x?y=1', 'box.ts.net')).toBe(true); + // On the box itself the browser reaches localhost directly. + expect(links.linkNeedsWebTabProxy('http://localhost:5173/', 'localhost')).toBe(false); + expect(links.linkNeedsWebTabProxy('http://localhost:5173/', '127.0.0.1')).toBe(false); + // A LAN address may be reachable from the device; leave it direct. + expect(links.linkNeedsWebTabProxy('http://192.168.1.135:3000/', '192.168.1.135')).toBe(false); + expect(links.linkNeedsWebTabProxy('http://10.9.0.4:8095/', '192.168.1.135')).toBe(false); + // Not a web URL at all. + expect(links.linkNeedsWebTabProxy('ftp://localhost/', '192.168.1.135')).toBe(false); + expect(links.linkNeedsWebTabProxy('not a url', '192.168.1.135')).toBe(false); + expect(links.linkNeedsWebTabProxy('', '192.168.1.135')).toBe(false); + }); +}); + +describe('openLinkThroughWebTabIfLoopback', () => { + it('reuses the saved proxied dashboard on that origin and opens the deep path', async () => { + const { win, app, calls } = boot(); + expect(app.openLinkThroughWebTabIfLoopback('http://localhost:5173/pages/report?tab=2#top')).toBe(true); + await vi.waitFor(() => expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/pages/report?tab=2#top')); + expect(calls.some((c) => c.path === '/api/webviews' && c.method === 'POST')).toBe(false); + expect(app.activeWebviewId).toBe('dev'); + expect(app.webviewOrder).toEqual(['dev']); + }); + + it('navigates an already-mounted frame instead of remounting it', async () => { + const { win, app } = boot(); + await app.openWebview('dev'); + const first = win.document.querySelector('.webview-frame[data-webview-id="dev"] iframe'); + expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/'); + + await app.openUrlInWebTab('http://localhost:5173/other'); + expect(win.document.querySelector('.webview-frame[data-webview-id="dev"] iframe')).toBe(first); + expect(frameSrc(win, 'dev')).toBe('/webview/cap-dev/other'); + expect(win.document.querySelectorAll('.webview-frame').length).toBe(1); + }); + + it('saves an unknown origin under its host:port, then opens it', async () => { + const { win, app, calls } = boot(); + expect(app.openLinkThroughWebTabIfLoopback('http://127.0.0.1:3000/')).toBe(true); + await vi.waitFor(() => expect(frameSrc(win, 'new-id')).toBe('/webview/cap-new-id/')); + const post = calls.find((c) => c.path === '/api/webviews' && c.method === 'POST'); + expect(post?.body).toEqual({ + name: '127.0.0.1:3000', + url: 'http://127.0.0.1:3000/', + embedMode: 'proxy', + trusted: false, + }); + }); + + it('does not reuse a direct-mode dashboard, which cannot show a loopback page from elsewhere', async () => { + const { win, app, calls } = boot(); + expect(app.openLinkThroughWebTabIfLoopback('https://localhost:9443/admin')).toBe(true); + await vi.waitFor(() => expect(frameSrc(win, 'new-id')).toBe('/webview/cap-new-id/admin')); + expect(calls.find((c) => c.method === 'POST' && c.path === '/api/webviews')?.body).toMatchObject({ + url: 'https://localhost:9443/', + }); + }); + + it('leaves a reachable link alone so the caller opens it directly', () => { + const { app, calls } = boot(); + expect(app.openLinkThroughWebTabIfLoopback('http://192.168.1.135:3000/')).toBe(false); + expect(app.openLinkThroughWebTabIfLoopback('https://example.com/')).toBe(false); + expect(calls).toHaveLength(0); + }); + + it('opens loopback links directly when the page itself is on the box', () => { + const { app, calls } = boot('http://localhost:8095/'); + expect(app.openLinkThroughWebTabIfLoopback('http://localhost:5173/')).toBe(false); + expect(calls).toHaveLength(0); + }); +}); + +describe('callers consult the hook first', () => { + it('terminal URL links try the web tab before window.open', () => { + const activate = TERMINAL_UI.indexOf('activate(_event, text) {'); + expect(activate).toBeGreaterThan(-1); + const body = TERMINAL_UI.slice(activate, TERMINAL_UI.indexOf('},', activate)); + expect(body.indexOf('openLinkThroughWebTabIfLoopback?.(text)')).toBeGreaterThan(-1); + expect(body.indexOf('openLinkThroughWebTabIfLoopback?.(text)')).toBeLessThan(body.indexOf('window.open(')); + }); + + it('response viewer links route through the hook and keep the file-path handler first', () => { + const bind = APP_JS.indexOf('_bindResponseViewerInteractions(body) {'); + const section = APP_JS.slice(bind, bind + 2500); + const pathHandler = section.indexOf("closest('a.rv-path')"); + const urlHandler = section.indexOf("closest('a[href]')"); + expect(pathHandler).toBeGreaterThan(-1); + expect(urlHandler).toBeGreaterThan(pathHandler); + expect(section.indexOf('openLinkThroughWebTabIfLoopback?.(urlLink.href)')).toBeGreaterThan(urlHandler); + }); +}); From 349a89ec3babe16783c98ff2dbd205404dfe64a9 Mon Sep 17 00:00:00 2001 From: shenlvkang-collab Date: Thu, 10 Sep 2026 14:13:15 +0800 Subject: [PATCH 2/2] fix(webview): let a proxied single-page app route on its own path, and recover a frame that reloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (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 --- .changeset/fix-webview-route-masking.md | 18 +++++ docs/web-tabs.md | 25 ++++++- src/web/middleware/auth.ts | 36 ++++++++- src/web/public/webview-tabs.js | 53 +++++++++++++- src/web/server.ts | 10 +++ src/web/webview-proxy.ts | 86 +++++++++++++++++++++- test/webview-auth-exemption.test.ts | 38 ++++++++++ test/webview-loopback-links.test.ts | 71 ++++++++++++++++++ test/webview-proxy.test.ts | 97 +++++++++++++++++++++++++ 9 files changed, 424 insertions(+), 10 deletions(-) create mode 100644 .changeset/fix-webview-route-masking.md 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 753a607dd..7f6be80e5 100644 --- a/docs/web-tabs.md +++ b/docs/web-tabs.md @@ -147,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 `