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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ Supports `zsh`, `bash`, `fish`, and `powershell`. Completion-only — run-kit ha
| `run-kit url` | Print the run-kit server URL (config-derived from `RK_HOST`/`RK_PORT`, default `http://127.0.0.1:3000`) — a heuristic for AI agents, not a liveness probe. |
| `run-kit skill` | Print the agent skill bundle — a static usage briefing for agents operating run-kit (canonical source `docs/site/skill.md`); `run-kit skill display` prints the visual-display topic page. |
| `run-kit notify` | Send a Web Push notification to your subscribed devices (see [Push notifications](#push-notifications)). Fail-silent. |
| `run-kit present` | Show a file, directory, `:port`, localhost URL, or external URL to the user as a web tile attached to the current window (`--window` spawns a standalone iframe window, `--notify` pushes). Prints the resolved URL. |
| `run-kit doctor` | Check runtime dependencies. Run this first when something breaks. |
| `run-kit agent-setup` | Install agent-harness hooks (v1: Claude Code) so panes report busy/waiting/idle state (see [Agent state](#agent-state--run-kit-agent-setup)), plus the tmux guard shim that blocks `tmux kill-server` without an explicit `-L`/`-S` socket. Once per machine; `--uninstall` reverses both. |
| `run-kit init-conf` | Scaffold default `tmux.conf` and `tmux.d/` drop-in directory to `~/.rk/`. Optional. |
Expand Down
138 changes: 138 additions & 0 deletions app/backend/api/present.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package api

// The /present/{windowId}/ content route (260813-becu-rk-present-attach-verb).
// It serves files for `rk present` file/dir targets with NO registration
// state: the serve root is read from the window's @rk_present_root tmux option
// AT REQUEST TIME (Constitution II/X — derive from tmux; the root lives in
// tmux and dies with the window). A dead window or unset option is a 404.
//
// Security (Constitution I, critical): serve only when the option is present
// and absolute, and verify the symlink-RESOLVED requested file stays contained
// under the symlink-RESOLVED root (filepath.Rel on the two EvalSymlinks
// results) — a containment check, never a lexical prefix/`..` ban (the
// code-server tarball lesson: intra-tree symlinks are legitimate; escaping
// ones are not). The tmux socket is already the trust boundary — anyone who
// can set window options can run code in panes — but path traversal through
// the web server must be impossible.

import (
"net/http"
"os"
"path/filepath"
"regexp"
"strings"

"github.com/go-chi/chi/v5"

"rk/internal/tmux"
)

// presentWindowIDPattern gates the {windowId} path param before any tmux
// subprocess runs (tmux window ids are @N).
var presentWindowIDPattern = regexp.MustCompile(`^@[0-9]+$`)

// presentRootOption is the window user option carrying the absolute serve root
// for /present/ requests, set by `rk present` for file/dir targets.
const presentRootOption = "@rk_present_root"

// getWindowOptionFn is the handler's tmux read seam, so the containment table
// is testable without a live server.
var getWindowOptionFn = tmux.GetWindowOption

// handlePresent serves GET /present/{windowId}/* from the window's
// request-time @rk_present_root.
func (s *Server) handlePresent(w http.ResponseWriter, r *http.Request) {
windowID := chi.URLParam(r, "windowId")
if !presentWindowIDPattern.MatchString(windowID) {
writeError(w, http.StatusBadRequest, "invalid windowId")
return
}

prefix := "/present/" + windowID
// Redirect /present/{windowId} → /present/{windowId}/ (308, query
// preserved) — the same relative-base rule as /proxy/{port}: apps resolve
// "./x" against the trailing-slash form.
if r.URL.Path == prefix {
target := r.URL.Path + "/"
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
http.Redirect(w, r, target, http.StatusPermanentRedirect)
return
}

server := serverFromRequest(r)
root, err := getWindowOptionFn(r.Context(), windowID, server, presentRootOption)
if err != nil || root == "" || !filepath.IsAbs(root) {
http.NotFound(w, r)
return
}

rel := strings.TrimPrefix(r.URL.Path, prefix+"/")
file, err := resolvePresentFile(root, rel)
if err != nil {
http.NotFound(w, r)
return
}
defer file.Close()

info, err := file.Stat()
if err != nil {
http.NotFound(w, r)
return
}
// ServeContent derives MIME from the name's extension (stdlib serving).
http.ServeContent(w, r, info.Name(), info.ModTime(), file)
}

// resolvePresentFile resolves rel under root with containment: both sides are
// symlink-evaluated and the result must stay under the resolved root. A path
// resolving to a directory serves that directory's index.html (never a
// listing). Every miss, escape, or error yields an error — the handler maps
// them all to 404 without touching files outside the root.
func resolvePresentFile(root, rel string) (*os.File, error) {
resolvedRoot, err := filepath.EvalSymlinks(root)
if err != nil {
return nil, err
}
return resolveContained(resolvedRoot, rel, 0)
}

// resolveContained joins rel onto resolvedRoot (already symlink-evaluated),
// evaluates symlinks on the result, and verifies containment. depth bounds the
// single index.html recursion.
func resolveContained(resolvedRoot, rel string, depth int) (*os.File, error) {
candidate := filepath.Join(resolvedRoot, filepath.FromSlash(rel))
resolved, err := filepath.EvalSymlinks(candidate)
if err != nil {
return nil, err
}
if !containedIn(resolvedRoot, resolved) {
return nil, os.ErrNotExist
}
info, err := os.Stat(resolved)
if err != nil {
return nil, err
}
if info.IsDir() {
if depth > 0 {
return nil, os.ErrNotExist // index.html resolving to a dir: no listing
}
return resolveContained(resolved, "index.html", depth+1)
}
if !info.Mode().IsRegular() {
return nil, os.ErrNotExist
}
return os.Open(resolved)
}

// containedIn reports whether resolved stays under resolvedRoot, comparing
// symlink-evaluated absolute paths via filepath.Rel — containment semantics,
// not a lexical prefix check.
func containedIn(resolvedRoot, resolved string) bool {
rel, err := filepath.Rel(resolvedRoot, resolved)
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
211 changes: 211 additions & 0 deletions app/backend/api/present_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
package api

import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)

// presentFixture builds a serve-root tree exercising the containment matrix:
//
// root/
// index.html (dir default)
// mock.html
// style.css
// real.html
// link.html → ./real.html (legitimate intra-tree symlink)
// evil → <outside>/ (escaping symlink — must never be served)
// sub/index.html
// noidx/ (no index.html → 404, never a listing)
// <outside>/secret.txt (escape target — must remain unread)
func presentFixture(t *testing.T) (root, outside string) {
t.Helper()
base := t.TempDir()
root = filepath.Join(base, "root")
outside = filepath.Join(base, "outside")
for _, dir := range []string{root, outside, filepath.Join(root, "sub"), filepath.Join(root, "noidx")} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
}
files := map[string]string{
filepath.Join(root, "index.html"): "<html>root index</html>",
filepath.Join(root, "mock.html"): "<html>mock</html>",
filepath.Join(root, "style.css"): "body{}",
filepath.Join(root, "real.html"): "<html>real</html>",
filepath.Join(root, "sub", "index.html"): "<html>sub index</html>",
filepath.Join(outside, "secret.txt"): "TOP-SECRET-OUTSIDE-ROOT",
}
for path, content := range files {
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
if err := os.Symlink("./real.html", filepath.Join(root, "link.html")); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(root, "evil")); err != nil {
t.Fatal(err)
}
return root, outside
}

// stubWindowOption installs the handler's tmux read seam, returning root for
// every read, and reports the (server, option) pairs it observed plus a call
// count (the invalid-windowId case must never reach tmux).
func stubWindowOption(t *testing.T, root string) (calls *int, servers *[]string) {
t.Helper()
n := 0
seen := []string{}
getWindowOptionFn = func(_ context.Context, _ /* windowID */, server, option string) (string, error) {
n++
seen = append(seen, server)
if option != presentRootOption {
t.Errorf("handler read option %q, want %q", option, presentRootOption)
}
return root, nil
}
// The seam is package-global, so restore the production default. This must
// be the ONLY cleanup touching the seam — t.Cleanup runs LIFO, so a second
// (e.g. nil-ing) cleanup registered before this one would run after it and
// leave the package in a broken state for later tests.
t.Cleanup(func() { getWindowOptionFn = defaultGetWindowOption })
return &n, &seen
}

// defaultGetWindowOption mirrors the handler's default seam value so tests can
// restore it (the tmux import is already named in present.go; re-pointing here
// keeps the test file's restore honest even if the default changes).
var defaultGetWindowOption = getWindowOptionFn

func getPresent(t *testing.T, router http.Handler, path string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}

func TestPresentServes(t *testing.T) {
root, _ := presentFixture(t)
stubWindowOption(t, root)
router := newTestRouter(&mockSessionFetcher{}, &mockTmuxOps{})

tests := []struct {
name string
path string
wantStatus int
wantBody string
wantMIME string
}{
{"plain html file", "/present/@7/mock.html?server=dev", 200, "<html>mock</html>", "text/html"},
{"css file", "/present/@7/style.css", 200, "body{}", "text/css"},
{"root dir serves index.html", "/present/@7/", 200, "<html>root index</html>", "text/html"},
{"subdir serves its index.html", "/present/@7/sub/", 200, "<html>sub index</html>", "text/html"},
{"dir without index is 404 not a listing", "/present/@7/noidx/", 404, "", ""},
{"missing file is 404", "/present/@7/nope.html", 404, "", ""},
{"intra-tree symlink serves", "/present/@7/link.html", 200, "<html>real</html>", "text/html"},
{"dotdot traversal is 404", "/present/@7/../outside/secret.txt", 404, "", ""},
{"deep dotdot traversal is 404", "/present/@7/sub/../../outside/secret.txt", 404, "", ""},
{"encoded dotdot traversal is 404", "/present/@7/%2e%2e/outside/secret.txt", 404, "", ""},
{"escaping symlink is 404", "/present/@7/evil/secret.txt", 404, "", ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
rec := getPresent(t, router, tc.path)
if rec.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d (body: %q)", rec.Code, tc.wantStatus, rec.Body.String())
}
if tc.wantBody != "" {
if body := strings.TrimSpace(rec.Body.String()); body != tc.wantBody {
t.Errorf("body = %q, want %q", body, tc.wantBody)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, tc.wantMIME) {
t.Errorf("Content-Type = %q, want prefix %q", ct, tc.wantMIME)
}
}
// The escape target's content must never appear, whatever the case.
if strings.Contains(rec.Body.String(), "TOP-SECRET") {
t.Error("response leaked a file outside the serve root")
}
})
}
}

func TestPresentBareWindowRedirects(t *testing.T) {
root, _ := presentFixture(t)
stubWindowOption(t, root)
router := newTestRouter(&mockSessionFetcher{}, &mockTmuxOps{})

rec := getPresent(t, router, "/present/@7?server=dev")
if rec.Code != http.StatusPermanentRedirect {
t.Fatalf("status = %d, want 308", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/present/@7/?server=dev" {
t.Errorf("Location = %q, want /present/@7/?server=dev (query preserved)", loc)
}
}

func TestPresentRootGate(t *testing.T) {
root, _ := presentFixture(t)
router := newTestRouter(&mockSessionFetcher{}, &mockTmuxOps{})

tests := []struct {
name string
root string // "" simulates an unset option (dead window reads empty)
}{
{"unset option is 404", ""},
{"relative root is 404", "relative/dir"},
{"nonexistent root is 404", filepath.Join(root, "ghost")},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
stubWindowOption(t, tc.root)
rec := getPresent(t, router, "/present/@7/mock.html")
if rec.Code != 404 {
t.Errorf("status = %d, want 404", rec.Code)
}
})
}
}

func TestPresentInvalidWindowIDNeverTouchesTmux(t *testing.T) {
root, _ := presentFixture(t)
calls, _ := stubWindowOption(t, root)
router := newTestRouter(&mockSessionFetcher{}, &mockTmuxOps{})

for _, path := range []string{"/present/7/mock.html", "/present/@x/mock.html", "/present/@/mock.html"} {
rec := getPresent(t, router, path)
if rec.Code == 200 {
t.Errorf("GET %s = 200, want rejection", path)
}
}
if *calls != 0 {
t.Errorf("tmux read seam called %d times for invalid windowIds, want 0 (gate before subprocess)", *calls)
}
}

func TestPresentServerParam(t *testing.T) {
root, _ := presentFixture(t)
_, servers := stubWindowOption(t, root)
router := newTestRouter(&mockSessionFetcher{}, &mockTmuxOps{})

getPresent(t, router, "/present/@7/mock.html?server=dev")
getPresent(t, router, "/present/@7/mock.html?server=bad%20name")
getPresent(t, router, "/present/@7/mock.html")

got := *servers
want := []string{"dev", "default", "default"} // invalid/absent → default
if len(got) != len(want) {
t.Fatalf("servers seen = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("call %d server = %q, want %q", i, got[i], want[i])
}
}
}
5 changes: 5 additions & 0 deletions app/backend/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,11 @@ func (s *Server) buildRouter() chi.Router {
r.HandleFunc("/proxy/{port}/*", s.handleProxy)
r.HandleFunc("/proxy/{port}", s.handleProxy)

// Content route for `rk present` file/dir targets — the serve root is the
// window's @rk_present_root option, read from tmux at request time.
r.HandleFunc("/present/{windowId}/*", s.handlePresent)
r.HandleFunc("/present/{windowId}", s.handlePresent)

// The stable code-server route (260811-a2bo) — same proxy machinery as
// /proxy/{port} with a FIXED pathname (workspace-state identity); the port
// is resolved server-side and never appears in a URL.
Expand Down
Loading
Loading