Skip to content

Supervisor threads: dev links in the viewer, pins on the running app, the laptop relay (PR 3b) - #215

Merged
hhff merged 16 commits into
mainfrom
threads-previews
Nov 13, 2026
Merged

hhff merged 16 commits into
mainfrom
threads-previews

Conversation

@hhff

@hhff hhff commented Nov 13, 2026 •

Copy link
Copy Markdown
Member

PR 3b, the last PR of supervisor threads. Spec: docs/superpowers/specs/2026-11-10-supervisor-threads-design.md §7. Plan: docs/superpowers/plans/2026-11-11-supervisor-threads-previews.md. Follows #185 (threads), #194 (gateway) and #205 (artifact viewers).

What the owner gets

  • Dev links in the viewer. A preview's Library card opens the running app here when you click its title, and in a tab of its own with Open. A question's [[name]] for a preview opens that tab too.
  • Pins on the running app. Hold Shift over the app and an element is named; click to start a thread on it. Pins sit on the right routes, survive hot reload (the element can move), and a navigation inside the app updates the route.
  • The supervisor relays dev-link threads. It passes the fix to the session that offered the preview (supervisor.md §7).
  • The laptop relay. On a laptop, the kit runs a small loopback relay per preview (ports 4800–4899) that does what the box's gateway does for a preview:
    • It serves the gateway's own lens; a test holds the bytes equal.
    • It forwards to the app with localhost as Host and Origin, and only answers its own Host.
    • It relaxes the app's framing only for its own lens, narrowed to 'self', the hub and this dashboard, including for apps that send no framing headers.
    • It refuses cross-site frames.
    • It carries WebSockets only from its own Origin, at most 32 per preview, ends them when the relay closes, and drops a handshake with no answer after 10 s.
    • It never serves a dashboard: it refuses this dashboard's port and any port answering as one when it opens, and each tick probes its target again and closes it if a dashboard has come up there. Every request and socket it forwards carries x-devteam-relay: 1 (a page's own copy replaced), and a dashboard answers 403 to any request with that header on every route, so owner actions cannot pass through it even if one appears on the target port later.
    • It opens only from the dashboard's own /api/view, never from opening a URL, and closes with its preview.
    • It refuses to point at this dashboard or any devteam dashboard.
  • Frame rules. The dashboard's frame-src is computed per request from the live previews and relays. With none it stays exactly 'self'.

Trust boundary

  • The app is same-origin with its lens, so everything the lens says is treated as the app describing itself. Every rule from Supervisor threads: artifacts in a viewer, Shift and pins on them, published versions (PR 3a of 3) #205 applies:
    • only the stage frame, at the expected origin;
    • one answer per question minted for a real pointer event;
    • the page's Shift can only raise the overlay, and only with focus;
    • the click point must sit in the anchor's rect;
    • strings are cut, and stripped of capabilities and control/bidi characters;
    • floods are coalesced;
    • no owner text is posted to the frame.
  • go reaches only same-origin paths on the app (checked here and in the lens).
  • New: the dashboard refuses to run inside a frame of its own origin. It shows "Open the dashboard in its own tab" instead, so an app that navigates its frame to the dashboard can't put the live dashboard under the owner's pointer. The hub and the owner wrapper are cross-origin parents, so they're unaffected.

Deliberate differences from the gateway (on loopback, where the dev server is reachable directly anyway): the relay forwards a non-GET with a foreign Origin and cross-site non-navigate fetches, passes clear-site-data, and answers a down app with a plain 502. These are documented in the relay's header and the plan.

How it was checked

  • Reviews: Fable reviewed the plan. Each of the 9 tasks had its own review, the relay and stage ones by Fable. Fable then reviewed the whole branch adversarially, and its findings are fixed: the framing guard, refusing a dashboard's port, and the relay mark.
  • Local runs on Node 24:
    • test/dashboard.test.mjs 139/139
    • kit + gateway 149/149
    • whole suite: bounds 3/3, rest 438 pass + 1 Linux-only skip, kit 82/82 (before the final review fixes)
    • browser script in full, including a real Vite app through the relay: a thread on its element, the pin through a hot reload that moves it, the HMR socket on the relay, a navigation updating the route.
  • Flaky step fixed: the browser script's answer step was flaky on main and is now an idempotent retry.

Notes

  • Files beyond the plan's list: the spec §7 and plan docs, test/lib/ports.mjs (layout comment) and test/lib/chrome.mjs (evIn, for cross-origin frames).
  • No new source files.
  • Commit trailer: commit a97368c's trailer says Sonnet 5.5 rather than Opus 5.5. History isn't rewritten.

🤖 Generated with Claude Code

hhff and others added 16 commits November 13, 2026 10:58
… for the laptop's relay

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s the box's gateway does

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wn Origin only

A socket to a relay is taken on its own address alone (421 otherwise), from a
page of its own origin under that name (403 for a missing or foreign Origin),
origin-form only, and at most 32 at once per preview, released as each closes.
The app sees localhost as Host and Origin; bytes go both ways raw, and every
socket closes with the relay.

Also from Task 2's review: the relay refuses another site's frame with 403, as
the gateway does; previewStage says opened only to the ask that opened the
relay; and DEVTEAM_RELAY_PORTS (low-high) moves the relays' range for tests,
the refusal of new previews there moving with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…this laptop's relay

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ewer tab: Shift+click, pins on its routes, go

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tab; a question's [[name]] opens the viewer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fered it; the laptop's relay

The lens-refusal note compares the blocked address's origin, not a prefix.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…h reload paths; who may frame the lens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ment, and the pin through hot reload

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…et goes to Vite

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ay never serves a dashboard

The page now stops at the top of its head when its parent is a page of its own
origin (a preview's app or an artifact's page sent its frame here), so no poll or
action runs under the owner's pointer. A relay is never opened for this dashboard's
port or any port that answers a dashboard's hello, and a new registration refuses
both. The relay gives up on an app that takes a WebSocket and sends no head (10 s),
its divergences from the gateway are written down, and README, spec section 7, the
ports layout comment and the stage flood asserts are corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e from another origin

The two viewer subtests that framed the viewer under a page of its own origin now
frame it from a stand-in hub (the dashboard's frame-ancestors name the hub's
address), read through the frame's own script world, as the hub and the owner's
wrapper frame it. The shipped page carries no seam.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd a relay closes when its target becomes a dashboard

The relay sets x-devteam-relay on everything it forwards (a page's own copy replaced),
and the dashboard answers 403 to any request that has it, before any route. The tick
probes each open relay's target again. The own-dashboard check on registration also
reads settings.md's dashboard_port, and the lens policy and the relaxed app policy
name localhost for this dashboard as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hhff

hhff commented Nov 13, 2026

Copy link
Copy Markdown
Member Author

Re-reviewed at 5c429ce. Each fix is in the code:

  • relayUpHeaders drops any incoming x-devteam-relay and stamps it fresh, and the WebSocket path uses the same headers.
  • The dashboard refuses a marked request right after the host check, before any route. It has no upgrade handler, so there's no socket path around that.
  • relaysTidy runs after every preview tick, and previewPortIsDashboard is checked both at registration and when a relay starts.
  • Registration refuses settings.dashboard_port.
  • The lens CSP and frame-ancestors name localhost:<port>.

Kit tests on Node 24 pass locally, 82 of 82, and CI passes 9 of 9. Merging.

@hhff
hhff merged commit 8d0c546 into main Nov 13, 2026
16 of 17 checks passed
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.

1 participant