feat(webview): open localhost links through a proxied web tab from another device - #401
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
Each item is from the pre-merge review of the PR it names, applied on master rather than by pushing to a contributor branch. #400 (response viewer, shenlvkang-collab) - The brief view opened at `scrollTop = 0`, right when it was a single card holding the last row. Now that it renders the whole turn, the top is the turn's first narration line and the answer can be screens below it, while loadFullContext already scrolls to the bottom of the same turn. A multi-row turn now opens at its newest text; a single card still opens at the top. #401 (loopback links as web tabs, shenlvkang-collab) - Drop `*.localhost` from the auto-route set. Every other member is an address literal that can only mean this box; a `*.localhost` DNS name is not one, and a resolver with a search domain retries `evil.localhost` as `evil.localhost.<search domain>`. The link source is agent-written terminal output, so that set is the whole confinement on a tap that makes Codeman fetch a URL server-side and persist it. The page-side test stays broader (`isOnBoxHostname`), where a false positive only declines to proxy. - A link to the origin root navigated nothing: the path was flattened to '', which openWebview reads as "no deep link", leaving an open frame where it was. - `this.webviews` being set does not mean it is loaded. initWebviews() assigns a truthy empty map and only then awaits the list, so a tap during page load found nothing to reuse and POSTed a duplicate record. Join the in-flight refresh instead. - One dashboard per dev server rather than per host spelling, which is what the method's own comment already promised. - Toast on the auto-create: it writes webviews.json, broadcasts over SSE and adds a Run-dropdown row on every signed-in device, with a new tab as its only previous signal. #362 (remote omp continuation, timkjr) - Accept the allowlisted `mode === 'omp'` arm as-is; a blanket registry render would hand deepseek a locally-resolved --profile and bypass claude's own overlay. A registry-declared switch is the follow-up if a third mode needs it. - Revert the whole-file Prettier reformat of docs/remote-sessions.md (docs/ is hand-formatted and outside `npm run format`), keeping only the two new sections. - Correct three stale passages: architecture-invariants' `exec claude --dangerously-skip-permissions`, the `exec <cli>` paragraph (claude and omp now have their own arms, and the claude pane's PID is the login shell), and omp-integration's `-c 'omp'`. RemoteCommandMode gains deepseek and omp. - Add the missing `_maybeCaptureOmpSessionId` remote-guard test; the sibling guard in `_pinOmpRespawnId` had one and this path runs earlier, on the first idle turn. #388 (keyCode 229 recovery, aakhter) - Gate notifyCanonicalData on shouldSuppressTerminalQueryResponse and isTerminalFocusOrMouseReport. onData also carries the DA/DSR/CPR/OSC replies xterm answers during Ink redraws and its SGR mouse and focus reports; any of those landing between the keydown and the candidate's resolution was read as "xterm spoke for this keystroke", standing the recovery down and leaving the character dropped, worst on a busy agent pane. Reached through window.CodemanTerminalInput: the predicates live in a module IIFE that closes long before this call site, so bare references would throw into the surrounding try/catch and stop the notify from ever running. Every fix has a test that fails without it (verified by reverting each). Full gate green on the combined tree: 358 files, 6849 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Merged, thank you! Tapping Four things applied on master at merge time ( 1. 2. A link to the origin root navigated nothing. 3. The "already loaded" guard tested the wrong thing. 4. Two smaller ones. Origin comparison is keyed per dev server rather than per host spelling, so I also added the rule to CLAUDE.md under Web tabs, since two other modules now depend on it. Shipping in the next release. |
A Fable 5.1 reviewer read the whole release diff against 1.26.2 and returned SHIP WITH FIXES. These are its findings, verified before acting on each. **The changelog advertised a feature the code refuses (major).** The #401 changeset and docs/web-tabs.md both listed `*.localhost` in the loopback set. The follow-up in 02b0e27 moved it out of the auto-route set on security grounds and updated CLAUDE.md but neither of those, and that changeset becomes the 1.27.0 CHANGELOG entry: a user would have read the release notes, tapped `http://app.localhost:3000/` on a phone and got a connection error from a documented feature. Both corrected, and the user guide now says why it is excluded and that adding such a dashboard by hand still works. **Dictation delivered its text twice (minor, #388).** `keydownSnapshot` started `null`, so `keydownSnapshot ?? canonicalCount` at the input event read a counter xterm had ALREADY bumped: on a fresh page load with no keydown yet, xterm's own capture listener forwards the `insertText` itself (it is not gated behind a keydown), then the snapshot equals the bumped count, `count > snapshot` is false, and the controller emits the same text again. Reproduced directly against the module: it emitted `hello` for input xterm had already delivered. A `0` baseline restores that file's own invariant, that a missed recovery is acceptable and a duplicated keystroke is not. Two regression tests, covering both the xterm-already-delivered and genuinely-dropped halves. **The sorted rail's arrow-key walk followed the DOM (minor).** `_tabKeydownHandler` steps `querySelectorAll` order, which is `sessionOrder`, while a sorted rail paints its rows with the flex `order` property, so ArrowDown from the top card landed wherever that session happened to sit in the tab order. It now sorts its node list by the COMPUTED order first: computed rather than inline, because web tabs take their `order: 9999` from CSS and would otherwise read as 0 and lead the walk. This is the one place that follows the paint; the Alt+N badge, the drag model and the filter all still deliberately read the DOM. **A trusted dashboard was auto-reused by a tapped link (minor, #401).** The reuse loop skipped `managed` and direct-mode records but not `trusted`. A trusted frame is mounted with `allow-same-origin`, i.e. on Codeman's origin with the user's cookie, and these links come from agent output, which is the threat model the loopback allowlist was just narrowed for. An agent that can write into the dev server's tree could print a path that one tap opens inside that privileged frame. Excluded from auto-reuse, with a test; opening it from the Run dropdown is still an explicit action and unchanged. **Two documentation claims that were no longer true.** CLAUDE.md said test/location-overlay-commands.test.ts pins every remote pane command, but remote claude and remote omp now have their own arm in `buildRemoteLaunchCommand` and never reach `defaultRemoteCommandForMode`, which is what that test asserts, so it pins nothing for them and changing either arm will not fail it. Named the real pins instead. Also documented the arrow-key-walk exception in the rail paragraph. Left as follow-ups, deliberately: `POST /api/webviews` does not dedupe by URL server-side, so two devices tapping one link concurrently can still save two dashboards for one origin (pre-existing endpoint behaviour that #401 makes reachable by a tap), and the location-overlay golden should assert the real remote claude/omp commands rather than a branch neither reaches. Full gate green: 359 files, 6869 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
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 (docs/web-tabs.md) fetches from the server, where it works.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.openWebview(id, { path })appends it to the minted/webview/<cap>/prefix; a mounted frame is navigated, not torn down, so its state survives). Otherwise one is saved under itshost:port, sandboxed like any other web tab, so it is in the Run dropdown next time.localhost) every link opens directly.openLinkThroughWebTabIfLoopback(url), returns false when it declines, and both callers fall through to their existing behaviour (window.openin the link provider, the anchor's_blankdefault in the viewer).No server change: the proxy, the egress policy (loopback is explicitly allowed) and the capability model are as shipped.
Docs:
docs/web-tabs.md(new section). Changeset: minor.Test plan
test/webview-loopback-links.test.ts(new, jsdom): loopback spellings vs LAN/tailnet/invalid; page-on-box exemption; reuse of a saved proxied dashboard with a deep path; navigating a mounted frame in place; saving an unknown origin ashost:port; direct-mode dashboards not reused; reachable links left to the caller; both callers consult the hook before their own path.test/webview-menu-rows.test.ts,test/response-viewer-file-links.test.ts,test/response-viewer-external-links.test.ts,test/link-provider-regex.test.ts,test/frontend-public-tooling.test.tsunchanged and green.🤖 Generated with Claude Code
https://claude.ai/code/session_01McLWqCWBuQYGuPMScb4Aou