Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 12 additions & 2 deletions app/backend/api/health.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
40 changes: 40 additions & 0 deletions app/backend/api/health_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
}
})
}
143 changes: 143 additions & 0 deletions app/backend/api/open.go
Original file line number Diff line number Diff line change
@@ -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 <path> -a
// <app>` (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=<name>
// body: {"path": "<abs path>", "app": "<app id>"}
// 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
}
Loading
Loading