Skip to content

feat(webview): open localhost links through a proxied web tab from another device - #401

Merged
Ark0N merged 1 commit into
Ark0N:masterfrom
shenlvkang-collab:pr/loopback-links-webtab
Sep 12, 2026
Merged

feat(webview): open localhost links through a proxied web tab from another device#401
Ark0N merged 1 commit into
Ark0N:masterfrom
shenlvkang-collab:pr/loopback-links-webtab

Conversation

@shenlvkang-collab

Copy link
Copy Markdown
Contributor

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.

  • 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 (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 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 (a browser on localhost) every link opens directly.
  • One hook, openLinkThroughWebTabIfLoopback(url), returns false when it declines, and both callers fall through to their existing behaviour (window.open in the link provider, the anchor's _blank default 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 as host: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.ts unchanged and green.
  • prettier on all touched files.

🤖 Generated with Claude Code

https://claude.ai/code/session_01McLWqCWBuQYGuPMScb4Aou

…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
@Ark0N
Ark0N merged commit e35b68e into Ark0N:master Sep 12, 2026
2 checks passed
Ark0N pushed a commit that referenced this pull request Sep 12, 2026
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>
@Ark0N

Ark0N commented Sep 12, 2026

Copy link
Copy Markdown
Owner

Merged, thank you! Tapping http://localhost:5173/ on a phone was a guaranteed connection error, and routing it through the proxy that already fetches server-side is the right shape for the fix. Reusing a saved same-origin dashboard instead of minting one per link, and navigating a mounted frame rather than tearing it down, are both the details that make this feel finished rather than bolted on.

Four things applied on master at merge time (02b0e278), each with a test that fails without it:

1. *.localhost is out of the auto-route set. This is the one I want to explain properly, because your implementation was reasonable and the reasoning is not obvious. Every other member of that allowlist is an address literal that can only ever mean this box. *.localhost is a DNS name: on a resolver that does not synthesise it locally and has a search domain configured, evil.localhost NXDOMAINs as absolute and gets retried as evil.localhost.<search domain>, which an attacker can control. The link source here is agent-written terminal output and response-viewer markdown, i.e. prompt-injectable, so that allowlist is the entire confinement on a tap that makes Codeman fetch a URL server-side and persist it. I could not build a live exploit (the frame is sandboxed without allow-same-origin, credentials are stripped upstream, the proxy is origin-locked), so this is hardening rather than a hole, but it is a one-word change now and a behaviour regression later. Someone running api.localhost can still save it by hand, which is an explicit action. The page-side test stays broader (isOnBoxHostname), since a false positive there only ever declines to proxy.

2. A link to the origin root navigated nothing. path === / ? : path flattened it, and openWebview reads an empty path as "no deep link", so it mounted with navigate: false and an already-open frame stayed on whatever page it was showing. Deep links navigated; a tap on the bare origin silently did not. / is now passed through.

3. The "already loaded" guard tested the wrong thing. if (!this.webviews) only catches "never initialised", but initWebviews() assigns a truthy EMPTY map synchronously and only then awaits GET /api/webviews, so a tap inside that round trip found nothing to reuse and POSTed a duplicate record for an origin that already existed server-side. It now joins the in-flight refresh.

4. Two smaller ones. Origin comparison is keyed per dev server rather than per host spelling, so localhost:5173 and 127.0.0.1:5173 no longer become two dashboards and two tabs for one server (which is what your own comment already promised). And the auto-create now toasts: it writes webviews.json, broadcasts over SSE and adds a Run-dropdown row on every device you are signed in on, and a new tab was its only signal.

I also added the rule to CLAUDE.md under Web tabs, since two other modules now depend on it.

Shipping in the next release.

Ark0N pushed a commit that referenced this pull request Sep 12, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants