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); + }); +});