From 69c556756dac4cae37bec5fccd10cdbe00014ea3 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 14 Jun 2026 13:52:51 +0200 Subject: [PATCH] fix(mir-signal): forward /registry in --webroot mode (404 in production) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B2.1 registered /registry on signal.Server.Handler(), but the production --webroot wrapper (withStatic) has its OWN hardcoded signalPaths map and /registry was missing from it — so the live relay 404'd /registry into the static file server. Tests used Server.Handler() directly and never saw the wrapper. Add /registry to signalPaths (and to sw.js SIGNALING so the SW doesn't cache it), plus TestWithStaticForwardsSignalingPaths which pins the production wrapper (it fails with the exact 404 when the route is dropped). Co-Authored-By: Claude Opus 4.8 --- go/cmd/mir-signal/main.go | 7 +++++- go/cmd/mir-signal/main_test.go | 40 ++++++++++++++++++++++++++++++++++ web/sw.js | 2 +- 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/go/cmd/mir-signal/main.go b/go/cmd/mir-signal/main.go index e699b9d..6843670 100644 --- a/go/cmd/mir-signal/main.go +++ b/go/cmd/mir-signal/main.go @@ -144,7 +144,12 @@ func newHTTPServer(addr string, handler http.Handler) *http.Server { func withStatic(sig http.Handler, dir string) http.Handler { fs := http.FileServer(http.Dir(dir)) indexPath := filepath.Join(dir, "index.html") - signalPaths := map[string]bool{"/agent/signal": true, "/attach": true, "/pair": true, "/turn-credentials": true, "/healthz": true} + // Every path the signal server owns must be forwarded here, or --webroot mode + // 404s it into the static file server. This list MUST stay in sync with the + // routes registered in signal.Server.Handler(); main_test.go's + // TestWithStaticForwardsSignalingPaths guards against drift (it caught /registry + // going missing in production). + signalPaths := map[string]bool{"/agent/signal": true, "/attach": true, "/pair": true, "/turn-credentials": true, "/healthz": true, "/registry": true} return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if signalPaths[r.URL.Path] { sig.ServeHTTP(w, r) diff --git a/go/cmd/mir-signal/main_test.go b/go/cmd/mir-signal/main_test.go index d90aa05..e26abeb 100644 --- a/go/cmd/mir-signal/main_test.go +++ b/go/cmd/mir-signal/main_test.go @@ -1,6 +1,7 @@ package main import ( + "io" "net/http" "net/http/httptest" "os" @@ -8,8 +9,47 @@ import ( "strings" "testing" "time" + + "github.com/srcful/terminal-relay/go/internal/signal" ) +// TestWithStaticForwardsSignalingPaths guards the production --webroot wiring: every +// path the signal server owns must be forwarded to it, not 404'd into the static +// file server. /registry shipped registered in signal.Server.Handler() but MISSING +// from withStatic's signalPaths map, so it 404'd in production (tests that used +// Server.Handler() directly never saw the wrapper). This pins the wrapper itself. +func TestWithStaticForwardsSignalingPaths(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("spa"), 0o644); err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(withStatic(signal.New().Handler(), dir)) + defer ts.Close() + + // /registry MUST be forwarded to the signal server (handleRegistry returns a JSON + // array for a wallet query) — not 404'd into the static FS. + resp, err := http.Get(ts.URL + "/registry?wallet=test") + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK || !strings.HasPrefix(strings.TrimSpace(string(body)), "[") { + t.Fatalf("/registry via withStatic = %d %q; want 200 JSON array (forwarded to signal, not static 404)", resp.StatusCode, body) + } + + // /healthz is forwarded too. + if r, err := http.Get(ts.URL + "/healthz"); err != nil || r.StatusCode != http.StatusOK { + t.Fatalf("/healthz via withStatic not forwarded (err=%v)", err) + } + + // A missing static asset still 404s via the FS — proves we aren't trivially + // forwarding everything to the signal server. + if r, err := http.Get(ts.URL + "/vendor/missing-asset.js"); err != nil || r.StatusCode != http.StatusNotFound { + t.Fatalf("a missing static asset should 404 via the FS (got err=%v)", err) + } +} + func TestNewHTTPServerSetsTimeouts(t *testing.T) { handler := http.NewServeMux() srv := newHTTPServer(":0", handler) diff --git a/web/sw.js b/web/sw.js index 40c4845..bc77cac 100644 --- a/web/sw.js +++ b/web/sw.js @@ -66,7 +66,7 @@ const SHELL = [ // Live conversations with the relay — never cached, never intercepted. // (WebSocket upgrades bypass the fetch handler anyway; this covers the plain // HTTP ones like /turn-credentials and keeps the list in one place.) -const SIGNALING = ['/agent/signal', '/attach', '/pair', '/turn-credentials', '/healthz']; +const SIGNALING = ['/agent/signal', '/attach', '/pair', '/turn-credentials', '/healthz', '/registry']; self.addEventListener('install', (event) => { event.waitUntil(