From 3b826d3dd96253bab19a06d0d0c1ee3402408bba Mon Sep 17 00:00:00 2001 From: Sahil Ahuja Date: Wed, 22 Jul 2026 17:39:32 +0530 Subject: [PATCH 1/2] feat: Navbar Open-in-App button Adds a navbar Open button to jump from a pane/worktree to that folder in an editor: server-side exec via wt open when local, client-side ssh-remote deeplinks (VS Code/Cursor/Windsurf) when remote. --- .env | 6 + app/backend/api/health.go | 14 +- app/backend/api/health_test.go | 40 +++ app/backend/api/open.go | 143 +++++++++ app/backend/api/open_test.go | 288 ++++++++++++++++++ app/backend/api/router.go | 55 ++++ app/backend/cmd/rk/serve.go | 7 +- app/backend/internal/config/config.go | 8 +- app/backend/internal/config/config_test.go | 18 ++ app/backend/internal/wt/wt.go | 96 ++++++ app/backend/internal/wt/wt_test.go | 156 ++++++++++ app/frontend/src/api/client.test.ts | 63 ++++ app/frontend/src/api/client.ts | 47 +++ app/frontend/src/app.tsx | 38 ++- .../src/components/open-button.test.tsx | 181 +++++++++++ app/frontend/src/components/open-button.tsx | 246 +++++++++++++++ app/frontend/src/components/top-bar.test.tsx | 59 ++++ app/frontend/src/components/top-bar.tsx | 34 +++ .../src/hooks/use-open-targets.test.tsx | 67 ++++ app/frontend/src/hooks/use-open-targets.ts | 72 +++++ app/frontend/src/lib/open-in-app.test.ts | 185 +++++++++++ app/frontend/src/lib/open-in-app.ts | 167 ++++++++++ app/frontend/src/lib/palette-open.test.ts | 58 ++++ app/frontend/src/lib/palette-open.ts | 54 ++++ app/frontend/tests/e2e/open-in-app.spec.md | 84 +++++ app/frontend/tests/e2e/open-in-app.spec.ts | 181 +++++++++++ app/frontend/tests/msw/handlers.ts | 5 + docs/memory/run-kit/architecture.md | 14 +- docs/memory/run-kit/ui-patterns.md | 39 ++- .../.history.jsonl | 11 + .../.status.yaml | 55 ++++ .../260722-6d0f-navbar-open-in-app/intake.md | 103 +++++++ .../260722-6d0f-navbar-open-in-app/plan.md | 282 +++++++++++++++++ 33 files changed, 2862 insertions(+), 14 deletions(-) create mode 100644 app/backend/api/open.go create mode 100644 app/backend/api/open_test.go create mode 100644 app/backend/internal/wt/wt.go create mode 100644 app/backend/internal/wt/wt_test.go create mode 100644 app/frontend/src/components/open-button.test.tsx create mode 100644 app/frontend/src/components/open-button.tsx create mode 100644 app/frontend/src/hooks/use-open-targets.test.tsx create mode 100644 app/frontend/src/hooks/use-open-targets.ts create mode 100644 app/frontend/src/lib/open-in-app.test.ts create mode 100644 app/frontend/src/lib/open-in-app.ts create mode 100644 app/frontend/src/lib/palette-open.test.ts create mode 100644 app/frontend/src/lib/palette-open.ts create mode 100644 app/frontend/tests/e2e/open-in-app.spec.md create mode 100644 app/frontend/tests/e2e/open-in-app.spec.ts create mode 100644 fab/changes/260722-6d0f-navbar-open-in-app/.history.jsonl create mode 100644 fab/changes/260722-6d0f-navbar-open-in-app/.status.yaml create mode 100644 fab/changes/260722-6d0f-navbar-open-in-app/intake.md create mode 100644 fab/changes/260722-6d0f-navbar-open-in-app/plan.md diff --git a/.env b/.env index 26dcadf4..b5d60c0b 100644 --- a/.env +++ b/.env @@ -6,3 +6,9 @@ # Prod mode: Go backend serves on this port directly. RK_PORT=3000 RK_HOST=0.0.0.0 + +# Optional: the SSH host alias remote clients use to reach this host +# (as configured in their ~/.ssh/config). Enables the Open button's editor +# ssh-remote deeplinks (VS Code / Cursor / Windsurf) for remote viewers. +# Unset: the deeplink section is hidden. +# RK_SSH_HOST=my-host diff --git a/app/backend/api/health.go b/app/backend/api/health.go index 5337e155..23084af3 100644 --- a/app/backend/api/health.go +++ b/app/backend/api/health.go @@ -4,9 +4,19 @@ import ( "net/http" ) +// handleHealth is the frontend's one-shot bootstrap surface as well as a +// liveness probe: alongside `status`/`hostname` it carries the optional +// `sshHost` (RK_SSH_HOST — the alias remote clients use to SSH to this host), +// which feeds the Open button's editor ssh-remote deeplinks. Omitted when +// unset (the frontend hides the deeplink section then) — a new /api/config +// route for one field would grow surface against Constitution IV. func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, map[string]string{ + body := map[string]string{ "status": "ok", "hostname": s.hostname, - }) + } + if s.sshHost != "" { + body["sshHost"] = s.sshHost + } + writeJSON(w, http.StatusOK, body) } diff --git a/app/backend/api/health_test.go b/app/backend/api/health_test.go index f8e96dda..aa4ee4b0 100644 --- a/app/backend/api/health_test.go +++ b/app/backend/api/health_test.go @@ -67,3 +67,43 @@ func TestHealthEndpointEmptyHostname(t *testing.T) { t.Errorf("body.hostname = %q, want %q", body["hostname"], "") } } + +// The optional sshHost field (RK_SSH_HOST) rides the health response: present +// when configured, absent (not empty-valued) when unset. +func TestHealthEndpointSSHHost(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + + t.Run("present when configured", func(t *testing.T) { + s := &Server{logger: logger, hostname: "test-host"} + s.SetSSHHost("devbox") + router := s.buildRouter() + + req := httptest.NewRequest(http.MethodGet, "/api/health", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + var body map[string]string + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if body["sshHost"] != "devbox" { + t.Errorf("body.sshHost = %q, want %q", body["sshHost"], "devbox") + } + }) + + t.Run("absent when unset", func(t *testing.T) { + router := NewTestRouter(logger, nil, nil, "test-host") + + req := httptest.NewRequest(http.MethodGet, "/api/health", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + var body map[string]string + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if _, present := body["sshHost"]; present { + t.Errorf("body.sshHost present (%q), want absent", body["sshHost"]) + } + }) +} diff --git a/app/backend/api/open.go b/app/backend/api/open.go new file mode 100644 index 00000000..3ccf949b --- /dev/null +++ b/app/backend/api/open.go @@ -0,0 +1,143 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "path/filepath" + "time" + + "rk/internal/sessions" + "rk/internal/wt" +) + +// open.go — the Open-in-App backend surface (260722-6d0f): +// +// - GET /api/open-apps — the host-detected app registry from +// `wt open --list --json`, degrading FAIL-SILENT to `[]` when wt is +// absent, older than the --list flag, or erroring (toolkit discipline: +// the frontend hides the "on host" section when the list is empty). +// - POST /api/open — launch an app on the host via `wt open -a +// ` (POST per Constitution IX). Both body fields are validated +// BEFORE exec (Constitution I): the path against the server-derived pane +// cwds / worktree paths (never trusting the client), the app id against +// the live registry. +// +// All wt interaction goes through the WtOps seam (Constitution III wrapper, +// internal/wt) so tests stub the wrapper. + +// openDeriveTimeout bounds the FetchSessions snapshot used for the path +// allowlist — the same 5s route-blocking budget the other tmux-derived +// handlers keep. +const openDeriveTimeout = 5 * time.Second + +// handleOpenApps returns the host app registry. +// +// GET /api/open-apps +// 200: [{"id":"vscode","label":"VS Code","kind":"editor"}, ...] +// 200: [] — wt absent / too old / erroring (fail-silent, never an error status) +func (s *Server) handleOpenApps(w http.ResponseWriter, r *http.Request) { + apps, err := s.wt.ListApps(r.Context()) + if err != nil || apps == nil { + // Fail-silent degradation: an absent or pre---list wt is an expected + // deployment state, not a server error. Debug-log for diagnosability. + if err != nil { + s.logger.Debug("open-apps: wt registry unavailable", "err", err) + } + apps = []wt.App{} + } + writeJSON(w, http.StatusOK, apps) +} + +// handleOpen launches a host app on a validated folder. +// +// POST /api/open?server= +// body: {"path": "", "app": ""} +// 200: {"ok": true} +// 400: invalid JSON; missing/relative path; path not derived from the +// server's panes/worktrees; app id not in the live registry +// 500: session snapshot unavailable +// 502: wt launch failed +func (s *Server) handleOpen(w http.ResponseWriter, r *http.Request) { + server := serverFromRequest(r) + + var body struct { + Path string `json:"path"` + App string `json:"app"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON body") + return + } + if body.Path == "" || !filepath.IsAbs(body.Path) { + writeError(w, http.StatusBadRequest, "path must be an absolute path") + return + } + if body.App == "" { + writeError(w, http.StatusBadRequest, "app is required") + return + } + + // Validate the app id against the LIVE registry. When the registry is + // unavailable (wt absent/old/erroring) no app is launchable — reject + // rather than exec blind. + apps, err := s.wt.ListApps(r.Context()) + if err != nil || !appInRegistry(apps, body.App) { + writeError(w, http.StatusBadRequest, "unknown app") + return + } + + // Validate the path against server-derived state (Constitution X): it + // must match a currently-derived pane cwd or window worktree path on the + // request's server. Dedicated timeout — the derivation feeds the launch. + ctx, cancel := context.WithTimeout(r.Context(), openDeriveTimeout) + defer cancel() + snapshot, err := s.sessions.FetchSessions(ctx, server) + if err != nil { + writeError(w, http.StatusInternalServerError, "Failed to derive session paths") + return + } + if !pathDerivedFromSessions(snapshot, body.Path) { + writeError(w, http.StatusBadRequest, "path is not a known pane or worktree path") + return + } + + if err := s.wt.Open(r.Context(), body.Path, body.App); err != nil { + s.logger.Error("open: wt launch failed", "path", body.Path, "app", body.App, "err", err) + writeError(w, http.StatusBadGateway, "Failed to launch app") + return + } + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// appInRegistry reports whether the app id is present in the registry. +func appInRegistry(apps []wt.App, id string) bool { + for _, a := range apps { + if a.ID == id { + return true + } + } + return false +} + +// pathDerivedFromSessions reports whether the candidate path matches a pane +// cwd or a window worktree path in the sessions snapshot. Both sides are +// filepath.Clean-normalized so trailing-slash variants of the same derived +// path compare equal; no other transformation is applied (the allowlist is +// exact-match by design — parents/children of a derived path do NOT pass). +func pathDerivedFromSessions(snapshot []sessions.ProjectSession, candidate string) bool { + want := filepath.Clean(candidate) + for _, sess := range snapshot { + for _, win := range sess.Windows { + if win.WorktreePath != "" && filepath.Clean(win.WorktreePath) == want { + return true + } + for _, pane := range win.Panes { + if pane.Cwd != "" && filepath.Clean(pane.Cwd) == want { + return true + } + } + } + } + return false +} diff --git a/app/backend/api/open_test.go b/app/backend/api/open_test.go new file mode 100644 index 00000000..fc097567 --- /dev/null +++ b/app/backend/api/open_test.go @@ -0,0 +1,288 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "testing" + + "rk/internal/sessions" + "rk/internal/tmux" + "rk/internal/wt" +) + +// mockWtOps stubs the WtOps seam for the open handlers. +type mockWtOps struct { + apps []wt.App + listErr error + + openCalled bool + openPath string + openApp string + openErr error +} + +func (m *mockWtOps) ListApps(ctx context.Context) ([]wt.App, error) { + return m.apps, m.listErr +} + +func (m *mockWtOps) Open(ctx context.Context, path, app string) error { + m.openCalled = true + m.openPath = path + m.openApp = app + return m.openErr +} + +// openTestSessions is a snapshot with one window: worktree path +// /Users/x/code/proj, active pane cwd /Users/x/code/proj/sub. +func openTestSessions() []sessions.ProjectSession { + return []sessions.ProjectSession{ + { + Name: "proj", + Windows: []tmux.WindowInfo{ + { + WindowID: "@1", + Name: "main", + WorktreePath: "/Users/x/code/proj", + Panes: []tmux.PaneInfo{ + {PaneID: "%1", Cwd: "/Users/x/code/proj/sub", IsActive: true}, + }, + }, + }, + }, + } +} + +func newOpenTestRouter(t *testing.T, wtOps WtOps, sf SessionFetcher) http.Handler { + t.Helper() + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + return NewTestRouterWithWt(logger, sf, nil, wtOps, "test-host") +} + +func TestOpenAppsEndpoint(t *testing.T) { + t.Run("returns the registry", func(t *testing.T) { + wtOps := &mockWtOps{apps: []wt.App{ + {ID: "vscode", Label: "VS Code", Kind: "editor"}, + {ID: "iterm", Label: "iTerm", Kind: "terminal"}, + }} + router := newOpenTestRouter(t, wtOps, &mockSessionFetcher{}) + + req := httptest.NewRequest(http.MethodGet, "/api/open-apps", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var got []wt.App + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != 2 || got[0].ID != "vscode" || got[1].ID != "iterm" { + t.Errorf("apps = %+v", got) + } + }) + + t.Run("degrades to 200 [] when the wrapper errors (wt absent/old)", func(t *testing.T) { + wtOps := &mockWtOps{listErr: errors.New("exec: wt: not found")} + router := newOpenTestRouter(t, wtOps, &mockSessionFetcher{}) + + req := httptest.NewRequest(http.MethodGet, "/api/open-apps", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if body := rec.Body.String(); body != "[]\n" { + t.Errorf("body = %q, want []", body) + } + }) + + t.Run("degrades to 200 [] for a nil registry", func(t *testing.T) { + wtOps := &mockWtOps{apps: nil} + router := newOpenTestRouter(t, wtOps, &mockSessionFetcher{}) + + req := httptest.NewRequest(http.MethodGet, "/api/open-apps", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK || rec.Body.String() != "[]\n" { + t.Errorf("status = %d body = %q, want 200 []", rec.Code, rec.Body.String()) + } + }) +} + +func postOpen(t *testing.T, router http.Handler, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/api/open", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +func TestOpenEndpoint(t *testing.T) { + registry := []wt.App{{ID: "vscode", Label: "VS Code", Kind: "editor"}} + + t.Run("launches a validated pane cwd", func(t *testing.T) { + wtOps := &mockWtOps{apps: registry} + router := newOpenTestRouter(t, wtOps, &mockSessionFetcher{result: openTestSessions()}) + + rec := postOpen(t, router, `{"path":"/Users/x/code/proj/sub","app":"vscode"}`) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String()) + } + if !wtOps.openCalled || wtOps.openPath != "/Users/x/code/proj/sub" || wtOps.openApp != "vscode" { + t.Errorf("open call = %v %q %q", wtOps.openCalled, wtOps.openPath, wtOps.openApp) + } + }) + + t.Run("launches a validated worktree path", func(t *testing.T) { + wtOps := &mockWtOps{apps: registry} + router := newOpenTestRouter(t, wtOps, &mockSessionFetcher{result: openTestSessions()}) + + rec := postOpen(t, router, `{"path":"/Users/x/code/proj","app":"vscode"}`) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !wtOps.openCalled { + t.Error("wrapper Open not called") + } + }) + + t.Run("rejects invalid JSON", func(t *testing.T) { + wtOps := &mockWtOps{apps: registry} + router := newOpenTestRouter(t, wtOps, &mockSessionFetcher{result: openTestSessions()}) + + rec := postOpen(t, router, `{not json`) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if wtOps.openCalled { + t.Error("Open must not be called on invalid JSON") + } + }) + + t.Run("rejects an empty path", func(t *testing.T) { + wtOps := &mockWtOps{apps: registry} + router := newOpenTestRouter(t, wtOps, &mockSessionFetcher{result: openTestSessions()}) + + rec := postOpen(t, router, `{"path":"","app":"vscode"}`) + + if rec.Code != http.StatusBadRequest || wtOps.openCalled { + t.Fatalf("status = %d openCalled = %v, want 400/false", rec.Code, wtOps.openCalled) + } + }) + + t.Run("rejects a relative path", func(t *testing.T) { + wtOps := &mockWtOps{apps: registry} + router := newOpenTestRouter(t, wtOps, &mockSessionFetcher{result: openTestSessions()}) + + rec := postOpen(t, router, `{"path":"code/proj","app":"vscode"}`) + + if rec.Code != http.StatusBadRequest || wtOps.openCalled { + t.Fatalf("status = %d openCalled = %v, want 400/false", rec.Code, wtOps.openCalled) + } + }) + + t.Run("rejects a path not derived from panes or worktrees", func(t *testing.T) { + wtOps := &mockWtOps{apps: registry} + router := newOpenTestRouter(t, wtOps, &mockSessionFetcher{result: openTestSessions()}) + + rec := postOpen(t, router, `{"path":"/etc","app":"vscode"}`) + + if rec.Code != http.StatusBadRequest || wtOps.openCalled { + t.Fatalf("status = %d openCalled = %v, want 400/false", rec.Code, wtOps.openCalled) + } + }) + + t.Run("rejects a parent of a derived path (exact-match allowlist)", func(t *testing.T) { + wtOps := &mockWtOps{apps: registry} + router := newOpenTestRouter(t, wtOps, &mockSessionFetcher{result: openTestSessions()}) + + rec := postOpen(t, router, `{"path":"/Users/x/code","app":"vscode"}`) + + if rec.Code != http.StatusBadRequest || wtOps.openCalled { + t.Fatalf("status = %d openCalled = %v, want 400/false", rec.Code, wtOps.openCalled) + } + }) + + t.Run("rejects an app id not in the registry", func(t *testing.T) { + wtOps := &mockWtOps{apps: registry} + router := newOpenTestRouter(t, wtOps, &mockSessionFetcher{result: openTestSessions()}) + + rec := postOpen(t, router, `{"path":"/Users/x/code/proj","app":"emacs"}`) + + if rec.Code != http.StatusBadRequest || wtOps.openCalled { + t.Fatalf("status = %d openCalled = %v, want 400/false", rec.Code, wtOps.openCalled) + } + }) + + t.Run("rejects every app when the registry errors (no blind launch)", func(t *testing.T) { + wtOps := &mockWtOps{listErr: errors.New("wt too old")} + router := newOpenTestRouter(t, wtOps, &mockSessionFetcher{result: openTestSessions()}) + + rec := postOpen(t, router, `{"path":"/Users/x/code/proj","app":"vscode"}`) + + if rec.Code != http.StatusBadRequest || wtOps.openCalled { + t.Fatalf("status = %d openCalled = %v, want 400/false", rec.Code, wtOps.openCalled) + } + }) + + t.Run("500 when the session snapshot fails", func(t *testing.T) { + wtOps := &mockWtOps{apps: registry} + router := newOpenTestRouter(t, wtOps, &mockSessionFetcher{err: errors.New("tmux down")}) + + rec := postOpen(t, router, `{"path":"/Users/x/code/proj","app":"vscode"}`) + + if rec.Code != http.StatusInternalServerError || wtOps.openCalled { + t.Fatalf("status = %d openCalled = %v, want 500/false", rec.Code, wtOps.openCalled) + } + }) + + t.Run("502 when the launch itself fails", func(t *testing.T) { + wtOps := &mockWtOps{apps: registry, openErr: errors.New("launch failed")} + router := newOpenTestRouter(t, wtOps, &mockSessionFetcher{result: openTestSessions()}) + + rec := postOpen(t, router, `{"path":"/Users/x/code/proj","app":"vscode"}`) + + if rec.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want 502", rec.Code) + } + }) +} + +func TestPathDerivedFromSessions(t *testing.T) { + snapshot := openTestSessions() + + cases := []struct { + name string + path string + want bool + }{ + {"pane cwd exact", "/Users/x/code/proj/sub", true}, + {"worktree path exact", "/Users/x/code/proj", true}, + {"trailing slash normalizes", "/Users/x/code/proj/", true}, + {"dot segments normalize", "/Users/x/code/proj/sub/../sub", true}, + {"parent rejected", "/Users/x/code", false}, + {"child rejected", "/Users/x/code/proj/sub/deeper", false}, + {"traversal out rejected", "/Users/x/code/proj/../../../etc", false}, + {"unrelated rejected", "/etc", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := pathDerivedFromSessions(snapshot, tc.path); got != tc.want { + t.Errorf("pathDerivedFromSessions(%q) = %v, want %v", tc.path, got, tc.want) + } + }) + } +} diff --git a/app/backend/api/router.go b/app/backend/api/router.go index 9e54fe8c..b0f5ae39 100644 --- a/app/backend/api/router.go +++ b/app/backend/api/router.go @@ -13,6 +13,7 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" + "rk/internal/config" "rk/internal/metrics" "rk/internal/ports" "rk/internal/prstatus" @@ -21,6 +22,7 @@ import ( "rk/internal/tmux" "rk/internal/updatecheck" "rk/internal/validate" + "rk/internal/wt" ) const ( @@ -112,13 +114,39 @@ func (prodRiffEngine) Spawn(ctx context.Context, opts riff.Options) (riff.Result return riff.Spawn(ctx, opts) } +// WtOps is the seam onto the internal/wt wrapper (Constitution III) used by +// the Open-in-App handlers. A DEDICATED dependency like RiffEngine — not +// folded into TmuxOps — so the shared mockTmuxOps stays untouched and the +// open handlers get their own focused stub. +type WtOps interface { + ListApps(ctx context.Context) ([]wt.App, error) + Open(ctx context.Context, path, app string) error +} + +// prodWtOps wraps the internal/wt package for production use. +type prodWtOps struct{} + +func (prodWtOps) ListApps(ctx context.Context) ([]wt.App, error) { + return wt.ListApps(ctx) +} + +func (prodWtOps) Open(ctx context.Context, path, app string) error { + return wt.Open(ctx, path, app) +} + // Server holds handler dependencies. type Server struct { logger *slog.Logger sessions SessionFetcher tmux TmuxOps riff RiffEngine + wt WtOps hostname string + // sshHost is the optional RK_SSH_HOST alias remote clients use to reach + // this host, surfaced on GET /api/health for the frontend's ssh-remote + // deeplinks. Seeded from config at startup (or SetSSHHost in tests); + // empty = unset = deeplink section hidden client-side. + sshHost string metrics *metrics.Collector services *ports.Collector prStatus *prstatus.Collector @@ -460,7 +488,9 @@ func NewRouterAndServer(ctx context.Context, logger *slog.Logger) (chi.Router, * sessions: &prodSessionFetcher{}, tmux: &prodTmuxOps{}, riff: prodRiffEngine{}, + wt: prodWtOps{}, hostname: hostname, + sshHost: config.Load().SSHHost, metrics: mc, services: svc, prStatus: pc, @@ -505,6 +535,26 @@ func NewTestRouterWithRiff(logger *slog.Logger, sf SessionFetcher, ops TmuxOps, return s.buildRouter() } +// NewTestRouterWithWt is NewTestRouter plus an injected WtOps, used by the +// open handler tests to stub the wt wrapper (mirrors NewTestRouterWithRiff). +func NewTestRouterWithWt(logger *slog.Logger, sf SessionFetcher, ops TmuxOps, wtOps WtOps, hostname string) chi.Router { + s := &Server{ + logger: logger, + sessions: sf, + tmux: ops, + wt: wtOps, + hostname: hostname, + } + return s.buildRouter() +} + +// SetSSHHost seeds the optional RK_SSH_HOST value surfaced on GET /api/health. +// Production wiring reads it from config in NewRouterAndServer; tests use this +// seam directly (mirrors SetVersion). +func (s *Server) SetSSHHost(host string) { + s.sshHost = host +} + func (s *Server) buildRouter() chi.Router { r := chi.NewRouter() @@ -560,6 +610,11 @@ func (s *Server) buildRouter() chi.Router { r.Post("/api/riff", s.handleRiffSpawn) r.Get("/api/riff/presets", s.handleRiffPresets) + // Open-in-App — host app registry (GET, fail-silent) + validated launch + // (POST per §IX). See api/open.go. + r.Get("/api/open-apps", s.handleOpenApps) + r.Post("/api/open", s.handleOpen) + // Server management routes r.Get("/api/servers", s.handleServersList) r.Post("/api/servers", s.handleServerCreate) diff --git a/app/backend/cmd/rk/serve.go b/app/backend/cmd/rk/serve.go index 0e06bbbf..bc3d09af 100644 --- a/app/backend/cmd/rk/serve.go +++ b/app/backend/cmd/rk/serve.go @@ -100,8 +100,11 @@ var serveCmd = &cobra.Command{ Long: `Start the HTTP server in the foreground. Environment variables: - RK_HOST Host to bind (default "127.0.0.1") - RK_PORT Port to bind (default 3000) + RK_HOST Host to bind (default "127.0.0.1") + RK_PORT Port to bind (default 3000) + RK_SSH_HOST Optional SSH host alias remote clients use to reach this host; + enables the Open button's editor ssh-remote deeplinks (unset: + the deeplink section is hidden) Examples: run-kit serve # foreground on 127.0.0.1:3000 diff --git a/app/backend/internal/config/config.go b/app/backend/internal/config/config.go index fd192eea..46f5754d 100644 --- a/app/backend/internal/config/config.go +++ b/app/backend/internal/config/config.go @@ -9,6 +9,10 @@ import ( type Config struct { Port int Host string + // SSHHost is the optional SSH host alias remote clients use to reach this + // host (env RK_SSH_HOST). It feeds the frontend's editor ssh-remote + // deeplinks; empty (unset) means the deeplink section stays hidden. + SSHHost string } var defaults = Config{ @@ -21,7 +25,7 @@ func validPort(p int) bool { return p >= 1 && p <= 65535 } -// Load reads configuration from RK_PORT and RK_HOST env vars, +// Load reads configuration from RK_PORT, RK_HOST, and RK_SSH_HOST env vars, // falling back to defaults. func Load() Config { cfg := defaults @@ -36,5 +40,7 @@ func Load() Config { cfg.Host = host } + cfg.SSHHost = os.Getenv("RK_SSH_HOST") + return cfg } diff --git a/app/backend/internal/config/config_test.go b/app/backend/internal/config/config_test.go index edbae29e..7e0f9b29 100644 --- a/app/backend/internal/config/config_test.go +++ b/app/backend/internal/config/config_test.go @@ -83,4 +83,22 @@ func TestLoad(t *testing.T) { t.Errorf("host = %q, want default %q", cfg.Host, defaults.Host) } }) + + t.Run("reads ssh host from env", func(t *testing.T) { + t.Setenv("RK_SSH_HOST", "devbox") + + cfg := Load() + if cfg.SSHHost != "devbox" { + t.Errorf("sshHost = %q, want devbox", cfg.SSHHost) + } + }) + + t.Run("ssh host empty when unset", func(t *testing.T) { + os.Unsetenv("RK_SSH_HOST") + + cfg := Load() + if cfg.SSHHost != "" { + t.Errorf("sshHost = %q, want empty", cfg.SSHHost) + } + }) } diff --git a/app/backend/internal/wt/wt.go b/app/backend/internal/wt/wt.go new file mode 100644 index 00000000..21137ef7 --- /dev/null +++ b/app/backend/internal/wt/wt.go @@ -0,0 +1,96 @@ +// Package wt wraps the fab-kit `wt` worktree CLI for host-app operations +// (Constitution III — wrap, don't reinvent). It carries exactly the two calls +// the Open-in-App feature needs: +// +// - ListApps — `wt open --list --json`, the host-detected app registry. +// The flag is NEW on the wt side (wt backlog [qj66]) and MUST NOT be +// assumed to exist at runtime: an absent wt, an older wt (unknown flag), +// or malformed output all surface as an error the API layer degrades to +// an empty registry (fail-silent toolkit discipline). +// - Open — `wt open -a `, the non-interactive launch path that +// exists in wt today. +// +// All subprocess calls use exec.CommandContext with explicit argument slices +// and their own timeouts (Constitution I / Process Execution) — callers' +// contexts bound the calls further but never extend them. +package wt + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + "time" +) + +const ( + // ListTimeout bounds `wt open --list --json` — a read-only registry probe. + ListTimeout = 5 * time.Second + // OpenTimeout bounds `wt open -a ` — a non-interactive app + // launch (spawn-and-return, not a build-class operation). + OpenTimeout = 10 * time.Second +) + +// App is one host-detected launch target from `wt open --list --json`. +// Kind is advisory (editor|terminal|file-manager today); unknown values pass +// through untouched. +type App struct { + ID string `json:"id"` + Label string `json:"label"` + Kind string `json:"kind,omitempty"` +} + +// ListApps runs `wt open --list --json` and returns the parsed registry. +// Errors (wt absent, unknown flag on an older wt, non-zero exit, non-JSON +// output) are returned as-is — the API layer owns the fail-silent-to-[] +// degradation so it stays observable and testable there. +func ListApps(parent context.Context) ([]App, error) { + ctx, cancel := context.WithTimeout(parent, ListTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "wt", "open", "--list", "--json") + var stderr strings.Builder + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("wt open --list --json: %w: %s", err, strings.TrimSpace(stderr.String())) + } + return parseApps(out) +} + +// parseApps decodes the registry JSON tolerantly: the payload must be a JSON +// array of objects; each entry requires non-empty `id` and `label` fields +// (entries missing either are skipped, not fatal); unknown fields are ignored +// (forward-compat with whatever wt adds later). Pure — testable without a wt +// invocation. +func parseApps(data []byte) ([]App, error) { + var raw []App + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parsing wt app registry: %w", err) + } + apps := make([]App, 0, len(raw)) + for _, a := range raw { + if a.ID == "" || a.Label == "" { + continue + } + apps = append(apps, a) + } + return apps, nil +} + +// Open runs `wt open -a `, launching the host app on the given +// folder. The caller is responsible for validating path and app BEFORE this +// call (Constitution I — nothing user-supplied reaches exec unchecked); this +// wrapper only shapes the argv and bounds the subprocess. +func Open(parent context.Context, path, app string) error { + ctx, cancel := context.WithTimeout(parent, OpenTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "wt", "open", path, "-a", app) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("wt open %s -a %s: %w: %s", path, app, err, strings.TrimSpace(string(out))) + } + return nil +} diff --git a/app/backend/internal/wt/wt_test.go b/app/backend/internal/wt/wt_test.go new file mode 100644 index 00000000..0481bbc4 --- /dev/null +++ b/app/backend/internal/wt/wt_test.go @@ -0,0 +1,156 @@ +package wt + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestParseApps(t *testing.T) { + t.Run("parses valid entries and ignores unknown fields", func(t *testing.T) { + data := []byte(`[ + {"id":"vscode","label":"VS Code","kind":"editor","future":"x"}, + {"id":"iterm","label":"iTerm","kind":"terminal"} + ]`) + apps, err := parseApps(data) + if err != nil { + t.Fatalf("parseApps error: %v", err) + } + if len(apps) != 2 { + t.Fatalf("len = %d, want 2", len(apps)) + } + if apps[0] != (App{ID: "vscode", Label: "VS Code", Kind: "editor"}) { + t.Errorf("apps[0] = %+v", apps[0]) + } + if apps[1] != (App{ID: "iterm", Label: "iTerm", Kind: "terminal"}) { + t.Errorf("apps[1] = %+v", apps[1]) + } + }) + + t.Run("skips entries missing id or label", func(t *testing.T) { + data := []byte(`[ + {"label":"No ID"}, + {"id":"nolabel"}, + {"id":"ok","label":"OK"} + ]`) + apps, err := parseApps(data) + if err != nil { + t.Fatalf("parseApps error: %v", err) + } + if len(apps) != 1 || apps[0].ID != "ok" { + t.Fatalf("apps = %+v, want single 'ok' entry", apps) + } + }) + + t.Run("kind is optional", func(t *testing.T) { + apps, err := parseApps([]byte(`[{"id":"a","label":"A"}]`)) + if err != nil { + t.Fatalf("parseApps error: %v", err) + } + if len(apps) != 1 || apps[0].Kind != "" { + t.Fatalf("apps = %+v, want one entry with empty kind", apps) + } + }) + + t.Run("empty array parses to empty slice", func(t *testing.T) { + apps, err := parseApps([]byte(`[]`)) + if err != nil { + t.Fatalf("parseApps error: %v", err) + } + if len(apps) != 0 { + t.Fatalf("len = %d, want 0", len(apps)) + } + }) + + t.Run("non-JSON output errors", func(t *testing.T) { + if _, err := parseApps([]byte("Usage: wt open ")); err == nil { + t.Fatal("expected error for non-JSON output") + } + }) + + t.Run("non-array JSON errors", func(t *testing.T) { + if _, err := parseApps([]byte(`{"id":"vscode"}`)); err == nil { + t.Fatal("expected error for non-array JSON") + } + }) +} + +// writeStub creates an executable stub script on a temp PATH dir — the same +// stub-exec pattern internal/riff uses for wt/tmux. +func writeStub(t *testing.T, dir, name, script string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(script), 0o755); err != nil { + t.Fatalf("WriteFile stub %s: %v", name, err) + } +} + +func TestListApps(t *testing.T) { + t.Run("returns parsed registry from a working wt", func(t *testing.T) { + dir := t.TempDir() + writeStub(t, dir, "wt", "#!/bin/sh\necho '[{\"id\":\"vscode\",\"label\":\"VS Code\",\"kind\":\"editor\"}]'\n") + t.Setenv("PATH", dir) + + apps, err := ListApps(context.Background()) + if err != nil { + t.Fatalf("ListApps error: %v", err) + } + if len(apps) != 1 || apps[0].ID != "vscode" { + t.Fatalf("apps = %+v, want single vscode entry", apps) + } + }) + + t.Run("errors when wt is absent", func(t *testing.T) { + t.Setenv("PATH", t.TempDir()) // empty dir — no wt + if _, err := ListApps(context.Background()); err == nil { + t.Fatal("expected error when wt is absent") + } + }) + + t.Run("errors when wt is too old (unknown flag, non-zero exit)", func(t *testing.T) { + dir := t.TempDir() + writeStub(t, dir, "wt", "#!/bin/sh\necho 'unknown flag: --list' >&2\nexit 2\n") + t.Setenv("PATH", dir) + if _, err := ListApps(context.Background()); err == nil { + t.Fatal("expected error for non-zero wt exit") + } + }) + + t.Run("errors on non-JSON stdout", func(t *testing.T) { + dir := t.TempDir() + writeStub(t, dir, "wt", "#!/bin/sh\necho 'Opened.'\n") + t.Setenv("PATH", dir) + if _, err := ListApps(context.Background()); err == nil { + t.Fatal("expected error for non-JSON output") + } + }) +} + +func TestOpen(t *testing.T) { + t.Run("invokes wt open -a ", func(t *testing.T) { + dir := t.TempDir() + argvLog := filepath.Join(dir, "argv.log") + writeStub(t, dir, "wt", "#!/bin/sh\necho \"$@\" > "+argvLog+"\n") + t.Setenv("PATH", dir) + + if err := Open(context.Background(), "/tmp/proj", "vscode"); err != nil { + t.Fatalf("Open error: %v", err) + } + got, err := os.ReadFile(argvLog) + if err != nil { + t.Fatalf("read argv log: %v", err) + } + if want := "open /tmp/proj -a vscode\n"; string(got) != want { + t.Errorf("argv = %q, want %q", got, want) + } + }) + + t.Run("propagates launch failure with output", func(t *testing.T) { + dir := t.TempDir() + writeStub(t, dir, "wt", "#!/bin/sh\necho 'no such app' >&2\nexit 1\n") + t.Setenv("PATH", dir) + if err := Open(context.Background(), "/tmp/proj", "nope"); err == nil { + t.Fatal("expected error for failing wt open") + } + }) +} diff --git a/app/frontend/src/api/client.test.ts b/app/frontend/src/api/client.test.ts index c814fe03..ad54f2e1 100644 --- a/app/frontend/src/api/client.test.ts +++ b/app/frontend/src/api/client.test.ts @@ -4,6 +4,8 @@ import { server as mswServer } from "../../tests/msw/server"; import { resetMockSessions } from "../../tests/msw/handlers"; import { getHealth, + getOpenApps, + openInApp, getSessions, getSessionOrder, setSessionOrder, @@ -47,6 +49,67 @@ describe("API client", () => { expect(health.hostname).toBe("test-host"); }); + it("getHealth surfaces the optional sshHost field when present", async () => { + mswServer.use( + http.get("/api/health", () => + HttpResponse.json({ status: "ok", hostname: "test-host", sshHost: "devbox" }), + ), + ); + const health = await getHealth(); + expect(health.sshHost).toBe("devbox"); + }); + + it("getOpenApps returns the registry array", async () => { + mswServer.use( + http.get("/api/open-apps", () => + HttpResponse.json([{ id: "vscode", label: "VS Code", kind: "editor" }]), + ), + ); + const apps = await getOpenApps(); + expect(apps).toEqual([{ id: "vscode", label: "VS Code", kind: "editor" }]); + }); + + it("getOpenApps is fail-silent: non-200 resolves to []", async () => { + mswServer.use( + http.get("/api/open-apps", () => + HttpResponse.json({ error: "boom" }, { status: 500 }), + ), + ); + await expect(getOpenApps()).resolves.toEqual([]); + }); + + it("getOpenApps is fail-silent: non-array body resolves to []", async () => { + mswServer.use( + http.get("/api/open-apps", () => HttpResponse.json({ nope: true })), + ); + await expect(getOpenApps()).resolves.toEqual([]); + }); + + it("openInApp POSTs path+app to /api/open with the server query", async () => { + let capturedUrl = ""; + let capturedBody: unknown = null; + mswServer.use( + http.post("/api/open", async ({ request }) => { + capturedUrl = request.url; + capturedBody = await request.json(); + return HttpResponse.json({ ok: true }); + }), + ); + const res = await openInApp("runkit", "/Users/x/code/proj", "vscode"); + expect(res.ok).toBe(true); + expect(capturedUrl).toContain("?server=runkit"); + expect(capturedBody).toEqual({ path: "/Users/x/code/proj", app: "vscode" }); + }); + + it("openInApp throws the server's error message on failure", async () => { + mswServer.use( + http.post("/api/open", () => + HttpResponse.json({ error: "unknown app" }, { status: 400 }), + ), + ); + await expect(openInApp("runkit", "/x", "nope")).rejects.toThrow("unknown app"); + }); + it("getSessions fetches GET /api/sessions with server query", async () => { let capturedUrl = ""; mswServer.use( diff --git a/app/frontend/src/api/client.ts b/app/frontend/src/api/client.ts index f4f50dd9..f63bc894 100644 --- a/app/frontend/src/api/client.ts +++ b/app/frontend/src/api/client.ts @@ -34,6 +34,10 @@ export async function throwOnError(res: Response): Promise { export interface HealthResponse { status: string; hostname: string; + /** Optional RK_SSH_HOST — the SSH alias remote clients use to reach this + * host. Feeds the Open button's editor ssh-remote deeplinks; absent when + * unset (the deeplink section is hidden then). */ + sshHost?: string; } export async function getHealth(): Promise { @@ -42,6 +46,49 @@ export async function getHealth(): Promise { return res.json(); } +/** One host-detected launch target from GET /api/open-apps (wt registry). */ +export interface OpenApp { + id: string; + label: string; + kind?: string; +} + +/** + * Fetch the host app registry for the Open button's "on host" section. + * Fail-silent end to end (mirrors the server's own degradation): any network + * error, non-200, or non-array body resolves to `[]` — an absent/old wt on + * the host must never surface as a client error. + */ +export async function getOpenApps(): Promise { + try { + const res = await deduplicatedFetch("/api/open-apps"); + if (!res.ok) return []; + const body: unknown = await res.json(); + return Array.isArray(body) ? (body as OpenApp[]) : []; + } catch { + return []; + } +} + +/** + * Launch a host app on a folder via POST /api/open. `path` must be a + * server-derived pane cwd / worktree path — the backend validates it against + * live tmux state and rejects anything else. + */ +export async function openInApp( + server: string, + path: string, + app: string, +): Promise<{ ok: boolean }> { + const res = await fetch(withServer("/api/open", server), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path, app }), + }); + if (!res.ok) await throwOnError(res); + return res.json(); +} + export async function getSessions(server: string): Promise { const res = await deduplicatedFetch(withServer("/api/sessions", server)); if (!res.ok) await throwOnError(res); diff --git a/app/frontend/src/app.tsx b/app/frontend/src/app.tsx index c1ded602..69a41b20 100644 --- a/app/frontend/src/app.tsx +++ b/app/frontend/src/app.tsx @@ -24,6 +24,10 @@ import { buildPinActions } from "@/lib/palette-pin"; import { buildServerKillActions } from "@/lib/palette-server-kill"; import { readLastPinnedBoard } from "@/lib/last-pinned-board"; import { buildNavActions } from "@/lib/palette-nav"; +import { buildOpenActions } from "@/lib/palette-open"; +import { activePaneCwd, buildOpenTargets, isLocalHostname } from "@/lib/open-in-app"; +import { useOpenTargets } from "@/hooks/use-open-targets"; +import { useRunOpenTarget } from "@/components/open-button"; import { nextWaitingTarget, chatSearchForTarget, type WaitingTarget } from "@/lib/palette-agent-nav"; import { isWaiting } from "@/lib/waiting"; import { useChatSubscription } from "@/hooks/use-chat-subscription"; @@ -1988,6 +1992,36 @@ function AppShell() { [windowParam, server, router, navigate], ); + // Open-in-App actions (260722-6d0f) — Constitution V palette parity for the + // top-bar Open split-button: one `Open: