From ff6db5e0fb364c083b0e06ca4c9d5febb6c8b8db Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 25 Jul 2026 16:35:17 +0200 Subject: [PATCH 1/3] feat: support odek serve per-instance WS token auth Current odek serve protects its WS and REST APIs with a per-instance token printed at startup. Resolve it in order: --token flag, ?token= in the attach --url, the stderr banner of a spawned server (scanned live via a passthrough writer), then a legacy cookie/probe fallback for odek versions that predate enforced auth. The token is sent as X-Odek-Ws-Token on every REST request and on the WS handshake. --- README.md | 18 ++- cmd/bodek/main.go | 13 +- internal/client/client.go | 25 +-- internal/client/client_ws_test.go | 56 +++++++ internal/client/rest.go | 3 + internal/server/server.go | 196 +++++++++++++++++++++--- internal/server/server_internal_test.go | 72 ++++++++- internal/server/server_test.go | 109 +++++++++++++ 8 files changed, 445 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 1c11ef8..fb7aef0 100644 --- a/README.md +++ b/README.md @@ -69,13 +69,21 @@ bodek looks for `odek` on your `PATH`. To point at a specific binary use ## Usage ```bash -bodek # launch odek serve and start chatting -bodek --sandbox # run tool calls inside odek's Docker sandbox -bodek --url http://127.0.0.1:8080 # attach to an already-running odek serve -bodek --odek-bin ./odek # use a specific odek binary -bodek -- --prompt-caching # pass extra flags through to `odek serve` +bodek # launch odek serve and start chatting +bodek --sandbox # run tool calls inside odek's Docker sandbox +bodek --url 'http://127.0.0.1:8080/?token=…' # attach with the token URL odek serve printed +bodek --url http://127.0.0.1:8080 --token d3adb33f # attach with an explicit token +bodek --odek-bin ./odek # use a specific odek binary +bodek -- --prompt-caching # pass extra flags through to `odek serve` ``` +`odek serve` protects its WebSocket and REST APIs with a per-instance token it +prints to stderr at startup (`odek serve ⚡ http://…/?token=…`). When bodek +spawns the server it picks the token up automatically; when you attach to one +that's already running, paste the token URL into `--url` or pass the token +itself via `--token`. Older odek versions without token enforcement are +detected and connected to as before. + Configuration (model, base URL, API key, MCP servers, memory, skills) is read by `odek serve` from its usual chain — `~/.odek/config.json` → `./odek.json` → `ODEK_*` env vars — so bodek inherits whatever you've already set up. diff --git a/cmd/bodek/main.go b/cmd/bodek/main.go index c6258e3..6e919fc 100644 --- a/cmd/bodek/main.go +++ b/cmd/bodek/main.go @@ -28,7 +28,8 @@ func main() { func run() error { var ( - url = flag.String("url", "", "attach to an already-running odek serve URL (e.g. http://127.0.0.1:8080)") + url = flag.String("url", "", "attach to an already-running odek serve URL (e.g. http://127.0.0.1:8080/?token=…)") + token = flag.String("token", "", "WS auth token for an attached odek serve (as printed at its startup)") sandbox = flag.Bool("sandbox", false, "run tool calls inside odek's Docker sandbox") bin = flag.String("odek-bin", "", "path to the odek binary to spawn (default: odek on PATH)") ) @@ -38,10 +39,11 @@ func run() error { fmt.Fprintf(os.Stderr, "Options:\n") flag.PrintDefaults() fmt.Fprintf(os.Stderr, "\nExamples:\n") - fmt.Fprintf(os.Stderr, " bodek # spawn odek serve and start chatting\n") - fmt.Fprintf(os.Stderr, " bodek --sandbox # spawn odek serve with Docker sandbox\n") - fmt.Fprintf(os.Stderr, " bodek --url http://127.0.0.1:8080 # attach to a running odek serve\n") - fmt.Fprintf(os.Stderr, " bodek -- --prompt-caching # pass extra flags to odek serve\n") + fmt.Fprintf(os.Stderr, " bodek # spawn odek serve and start chatting\n") + fmt.Fprintf(os.Stderr, " bodek --sandbox # spawn odek serve with Docker sandbox\n") + fmt.Fprintf(os.Stderr, " bodek --url 'http://127.0.0.1:8080/?token=…' # attach with the token URL odek serve printed\n") + fmt.Fprintf(os.Stderr, " bodek --url http://127.0.0.1:8080 --token d3adb33f # attach with an explicit token\n") + fmt.Fprintf(os.Stderr, " bodek -- --prompt-caching # pass extra flags to odek serve\n") } flag.Parse() @@ -74,6 +76,7 @@ func run() error { // Spawn or attach to the odek serve backend. srv, err := server.Connect(server.Options{ URL: *url, + Token: *token, Bin: *bin, Sandbox: *sandbox, ExtraArgs: extraArgs, diff --git a/internal/client/client.go b/internal/client/client.go index c483ff0..d7fc7ad 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -78,16 +78,18 @@ type Resource struct { // Client is a connected odek serve session. type Client struct { - conn *ws.Conn - baseURL string - http *http.Client - Events chan Event + conn *ws.Conn + baseURL string + serveToken string + http *http.Client + Events chan Event } // Dial connects to an odek serve WebSocket. wsURL is the ws:// endpoint, // origin is an http://localhost-based origin accepted by the server, baseURL is -// the http:// root (used for the resource-search API), and token is the -// per-instance CSRF token (obtained from a GET / Set-Cookie header). +// the http:// root (used for the REST APIs), and token is the per-instance +// CSRF token resolved by server.Connect (token URL, stderr banner, or legacy +// cookie). It is sent on the WS handshake and on every REST request. func Dial(wsURL, origin, baseURL, token string) (*Client, error) { cfg, err := ws.NewConfig(wsURL, origin) if err != nil { @@ -101,10 +103,11 @@ func Dial(wsURL, origin, baseURL, token string) (*Client, error) { } c := &Client{ - conn: conn, - baseURL: baseURL, - http: &http.Client{Timeout: 3 * time.Second}, - Events: make(chan Event, 256), + conn: conn, + baseURL: baseURL, + serveToken: token, + http: &http.Client{Timeout: 3 * time.Second}, + Events: make(chan Event, 256), } go c.readLoop() return c, nil @@ -114,7 +117,7 @@ func Dial(wsURL, origin, baseURL, token string) (*Client, error) { func (c *Client) Resources(query string, limit int) ([]Resource, error) { u := fmt.Sprintf("%s/api/resources?q=%s&limit=%d", c.baseURL, url.QueryEscape(query), limit) - resp, err := c.http.Get(u) + resp, err := c.do(http.MethodGet, u, "") if err != nil { return nil, err } diff --git a/internal/client/client_ws_test.go b/internal/client/client_ws_test.go index 2243b88..2c7aad3 100644 --- a/internal/client/client_ws_test.go +++ b/internal/client/client_ws_test.go @@ -5,6 +5,7 @@ import ( "net/http" "net/http/httptest" "strings" + "sync" "testing" "time" @@ -201,6 +202,61 @@ func TestRESTEndpoints(t *testing.T) { } } +func TestRESTCarriesServeToken(t *testing.T) { + // Every REST request must carry the per-instance serve token, mirroring + // odek serve's requireServeToken (cookie or X-Odek-Ws-Token header). + var seen []string + var mu sync.Mutex + record := func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + seen = append(seen, r.URL.Path+":"+r.Header.Get("X-Odek-Ws-Token")) + mu.Unlock() + } + mux := http.NewServeMux() + mux.Handle("/ws", ws.Handler(func(c *ws.Conn) { _, _ = c.Write(nil) })) + mux.HandleFunc("/api/sessions", func(w http.ResponseWriter, r *http.Request) { + record(w, r) + json.NewEncoder(w).Encode([]Session{}) + }) + mux.HandleFunc("/api/models", func(w http.ResponseWriter, r *http.Request) { + record(w, r) + json.NewEncoder(w).Encode([]ModelInfo{}) + }) + mux.HandleFunc("/api/resources", func(w http.ResponseWriter, r *http.Request) { + record(w, r) + json.NewEncoder(w).Encode([]Resource{}) + }) + mux.HandleFunc("/api/cancel", func(w http.ResponseWriter, r *http.Request) { + record(w, r) + w.WriteHeader(http.StatusNoContent) + }) + cl, _ := newTestServer(t, mux) + + if _, err := cl.Sessions(); err != nil { + t.Fatalf("Sessions: %v", err) + } + if _, err := cl.Models(); err != nil { + t.Fatalf("Models: %v", err) + } + if _, err := cl.Resources("x", 1); err != nil { + t.Fatalf("Resources: %v", err) + } + if err := cl.Cancel("s1", "tok"); err != nil { + t.Fatalf("Cancel: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(seen) != 4 { + t.Fatalf("expected 4 API calls, got %v", seen) + } + for _, s := range seen { + if !strings.HasSuffix(s, ":test-token") { + t.Errorf("request missing serve token header: %q", s) + } + } +} + func TestRESTErrorStatuses(t *testing.T) { mux := http.NewServeMux() mux.Handle("/ws", ws.Handler(func(c *ws.Conn) {})) diff --git a/internal/client/rest.go b/internal/client/rest.go index 22e3035..83689d6 100644 --- a/internal/client/rest.go +++ b/internal/client/rest.go @@ -110,6 +110,9 @@ func (c *Client) do(method, u, sessionToken string) (*http.Response, error) { if err != nil { return nil, err } + if c.serveToken != "" { + req.Header.Set("X-Odek-Ws-Token", c.serveToken) + } if sessionToken != "" { req.Header.Set("X-Session-Token", sessionToken) } diff --git a/internal/server/server.go b/internal/server/server.go index 2fe1027..fb2438e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -4,14 +4,17 @@ package server import ( + "bytes" "context" "fmt" "io" "net" "net/http" + "net/url" "os" "os/exec" "strings" + "sync" "time" ) @@ -28,15 +31,21 @@ type Conn struct { Origin string // http://127.0.0.1:port (accepted by the server's origin check) Token string // per-instance CSRF token - proc *exec.Cmd // non-nil when bodek spawned the server + proc *exec.Cmd // non-nil when bodek spawned the server + scan *tokenScanWriter // non-nil when bodek spawned the server } // Options configures how the odek serve instance is obtained. type Options struct { // URL of an already-running odek serve (e.g. "http://127.0.0.1:8080"). + // A "?token=…" query (as printed by odek serve) is honored and stripped. // When set, bodek attaches instead of spawning. URL string + // Token is the per-instance WS auth token, given explicitly (e.g. via + // --token). It takes precedence over a token embedded in URL. + Token string + // Bin is the odek binary to spawn (default "odek"). Ignored when URL set. Bin string @@ -53,14 +62,26 @@ type Options struct { // Connect attaches to or launches an odek serve instance and resolves its // auth token, returning a ready Conn. +// +// Token resolution order: Options.Token, then "?token=" in Options.URL, then +// the "WS token:" line a spawned odek serve prints to stderr, then a legacy +// fallback for servers that predate enforced auth. func Connect(opts Options) (*Conn, error) { c := &Conn{} + token := opts.Token if opts.URL != "" { - base := strings.TrimRight(opts.URL, "/") + base, urlToken := splitTokenURL(opts.URL) + base = strings.TrimRight(base, "/") c.BaseURL = base c.Origin = base c.WSURL = "ws" + strings.TrimPrefix(base, "http") + "/ws" + if token == "" { + token = urlToken + } + if err := waitReady(c.BaseURL, readyTimeout); err != nil { + return nil, fmt.Errorf("odek serve did not become ready: %w", err) + } } else { port, err := freePort() if err != nil { @@ -73,17 +94,24 @@ func Connect(opts Options) (*Conn, error) { if err := c.spawn(opts, addr); err != nil { return nil, err } + // A current odek prints its token to stderr at startup; an older one + // prints nothing, so stop waiting as soon as the server answers. + if err := waitSpawned(c.BaseURL, c.scan, readyTimeout); err != nil { + c.Stop() + return nil, fmt.Errorf("odek serve did not become ready: %w", err) + } + if token == "" { + token = c.scan.Token() + } } - if err := waitReady(c.BaseURL, readyTimeout); err != nil { - c.Stop() - return nil, fmt.Errorf("odek serve did not become ready: %w", err) - } - - token, err := fetchToken(c.BaseURL) - if err != nil { - c.Stop() - return nil, fmt.Errorf("fetch auth token: %w", err) + if token == "" { + legacy, err := legacyToken(c.BaseURL) + if err != nil { + c.Stop() + return nil, err + } + token = legacy } c.Token = token return c, nil @@ -105,8 +133,14 @@ func (c *Conn) spawn(opts Options, addr string) error { } args = append(args, opts.ExtraArgs...) + stderr := opts.Stderr + if stderr == nil { + stderr = io.Discard + } + c.scan = &tokenScanWriter{w: stderr} + cmd := exec.Command(bin, args...) - cmd.Stderr = opts.Stderr + cmd.Stderr = c.scan cmd.Env = os.Environ() if err := cmd.Start(); err != nil { return fmt.Errorf("start odek serve: %w", err) @@ -133,6 +167,81 @@ func (c *Conn) Stop() { } } +// splitTokenURL separates an attach URL into its base and an optional +// "?token=" value, so users can paste the exact URL odek serve prints. +func splitTokenURL(raw string) (base, token string) { + u, err := url.Parse(raw) + if err != nil { + return raw, "" + } + token = u.Query().Get("token") + u.RawQuery = "" + u.Fragment = "" + return u.String(), token +} + +// tokenScanWriter passes a spawned server's stderr through unchanged while +// scanning each line for the per-instance token odek serve prints at startup: +// +// odek serve ⚡ http://127.0.0.1:8080/?token= +// WebSocket: ws://127.0.0.1:8080/ws +// WS token: +type tokenScanWriter struct { + w io.Writer + mu sync.Mutex + buf []byte // partial line not yet terminated by '\n' + tok string +} + +func (s *tokenScanWriter) Write(p []byte) (int, error) { + s.scan(p) + return s.w.Write(p) +} + +// Token returns the scanned token, or "" if no token line was seen yet. +func (s *tokenScanWriter) Token() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.tok +} + +func (s *tokenScanWriter) scan(p []byte) { + s.mu.Lock() + defer s.mu.Unlock() + if s.tok != "" { + return // already found; keep passing bytes through + } + s.buf = append(s.buf, p...) + for { + i := bytes.IndexByte(s.buf, '\n') + if i < 0 { + return + } + line := string(s.buf[:i]) + s.buf = s.buf[i+1:] + if tok := parseTokenLine(line); tok != "" { + s.tok = tok + return + } + } +} + +// parseTokenLine extracts the token from a "WS token: " line, falling +// back to the "?token=" query in the "odek serve ⚡ " banner line. +func parseTokenLine(line string) string { + if i := strings.Index(line, "WS token:"); i >= 0 { + return strings.TrimSpace(line[i+len("WS token:"):]) + } + if i := strings.Index(line, "?token="); i >= 0 { + tok := line[i+len("?token="):] + if j := strings.IndexAny(tok, "& \t\r"); j >= 0 { + tok = tok[:j] + } + return tok + } + return "" +} + // freePort asks the OS for an unused TCP port on the loopback interface. func freePort() (int, error) { l, err := net.Listen("tcp", "127.0.0.1:0") @@ -146,25 +255,66 @@ func freePort() (int, error) { // waitReady polls the server root until it responds or the timeout elapses. func waitReady(baseURL string, timeout time.Duration) error { deadline := time.Now().Add(timeout) - client := &http.Client{Timeout: 2 * time.Second} for time.Now().Before(deadline) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/", nil) - resp, err := client.Do(req) - cancel() - if err == nil { - _ = resp.Body.Close() - if resp.StatusCode < 500 { - return nil - } + if probeReady(baseURL) { + return nil + } + time.Sleep(150 * time.Millisecond) + } + return fmt.Errorf("timed out after %s", timeout) +} + +// waitSpawned waits until a spawned server prints its token line or answers +// HTTP, whichever comes first. Old odek versions print no token line, so +// readiness alone also ends the wait (the legacy token path handles those). +func waitSpawned(baseURL string, scan *tokenScanWriter, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if scan.Token() != "" || probeReady(baseURL) { + return nil } time.Sleep(150 * time.Millisecond) } return fmt.Errorf("timed out after %s", timeout) } +// probeReady reports whether the server root answers without a server error. +func probeReady(baseURL string) bool { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/", nil) + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Do(req) + if err != nil { + return false + } + _ = resp.Body.Close() + return resp.StatusCode < 500 +} + +// legacyToken resolves the token against servers that did not provide one up +// front: try the old cookie-based fetch first, then probe whether the API +// enforces auth at all (old odek versions did not). +func legacyToken(baseURL string) (string, error) { + if tok, err := fetchToken(baseURL); err == nil { + return tok, nil + } + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get(baseURL + "/api/models") + if err != nil { + return "", fmt.Errorf("probe auth enforcement: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode == http.StatusForbidden { + return "", fmt.Errorf("this odek serve requires a WS token — attach with the token URL it printed (bodek --url 'http://127.0.0.1:8080/?token=…') or pass --token") + } + return "", nil +} + // fetchToken performs GET / and reads the per-instance CSRF token from the -// odek_ws_token Set-Cookie header. +// odek_ws_token Set-Cookie header. Current odek serve only sets the cookie +// when the request URL carries "?token=", so a plain GET / usually yields +// nothing — callers must treat that as "no cookie", not a hard failure. func fetchToken(baseURL string) (string, error) { client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Get(baseURL + "/") diff --git a/internal/server/server_internal_test.go b/internal/server/server_internal_test.go index 787b2df..7da749e 100644 --- a/internal/server/server_internal_test.go +++ b/internal/server/server_internal_test.go @@ -1,10 +1,15 @@ package server import ( + "bytes" + "fmt" + "io" "net/http" "net/http/httptest" "os" "os/exec" + "path/filepath" + "strings" "testing" "time" ) @@ -75,13 +80,74 @@ func TestConnectSpawnNotReady(t *testing.T) { } func TestConnectFetchTokenFailure(t *testing.T) { - // Server is ready but never issues the token cookie → Connect fails at - // fetchToken and tears down. + // Server is ready but enforces auth without ever issuing a token cookie → + // the legacy fallback probe 403s and Connect fails. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/models" { + w.WriteHeader(http.StatusForbidden) + return + } w.WriteHeader(http.StatusOK) })) defer srv.Close() if _, err := Connect(Options{URL: srv.URL}); err == nil { - t.Error("expected Connect to fail without a token cookie") + t.Error("expected Connect to fail when auth is enforced and no token is available") + } +} + +// fakeOdekScript writes a shell script that mimics `odek serve` startup output +// on stderr and then exits, so Connect can scan the token from it. +func fakeOdekScript(t *testing.T, stderrLines ...string) string { + t.Helper() + var b strings.Builder + b.WriteString("#!/bin/sh\n") + for _, l := range stderrLines { + fmt.Fprintf(&b, "echo '%s' >&2\n", l) + } + path := filepath.Join(t.TempDir(), "odek-fake") + if err := os.WriteFile(path, []byte(b.String()), 0o755); err != nil { + t.Fatalf("write fake odek: %v", err) + } + return path +} + +func TestConnectSpawnTokenFromStderr(t *testing.T) { + // A current odek serve prints its token to stderr; Connect must pick it up + // from the "WS token:" line while passing stderr through verbatim. + bin := fakeOdekScript(t, + "odek serve ⚡ http://127.0.0.1:9999/?token=cafef00d", + " WebSocket: ws://127.0.0.1:9999/ws", + " WS token: cafef00d", + ) + var stderr bytes.Buffer + conn, err := Connect(Options{Bin: bin, Stderr: &stderr}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + if conn.Token != "cafef00d" { + t.Errorf("Token = %q, want %q", conn.Token, "cafef00d") + } + conn.Stop() // wait for the child's stderr copier to finish before reading + for _, want := range []string{"odek serve ⚡", "WebSocket:", "WS token: cafef00d"} { + if !strings.Contains(stderr.String(), want) { + t.Errorf("stderr passthrough missing %q, got:\n%s", want, stderr.String()) + } + } +} + +func TestConnectSpawnTokenFromBannerFallback(t *testing.T) { + // If the "WS token:" line is absent, the ?token= query in the banner URL + // is the fallback. + bin := fakeOdekScript(t, + "odek serve ⚡ http://127.0.0.1:9999/?token=beefcafe", + " WebSocket: ws://127.0.0.1:9999/ws", + ) + conn, err := Connect(Options{Bin: bin, Stderr: io.Discard}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer conn.Stop() + if conn.Token != "beefcafe" { + t.Errorf("Token = %q, want %q", conn.Token, "beefcafe") } } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 69b11c5..7ee731a 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -3,6 +3,7 @@ package server import ( "net/http" "net/http/httptest" + "strings" "testing" "time" ) @@ -86,6 +87,114 @@ func TestConnectViaURL(t *testing.T) { conn.Stop() // no spawned process — must be a no-op } +func TestConnectURLTokenQuery(t *testing.T) { + // The exact URL odek serve prints carries ?token=; it must be honored and + // stripped before deriving the connection URLs. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + conn, err := Connect(Options{URL: srv.URL + "/?token=qtok"}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + if conn.Token != "qtok" { + t.Errorf("Token = %q, want %q", conn.Token, "qtok") + } + if conn.BaseURL != srv.URL { + t.Errorf("BaseURL = %q, want query stripped to %q", conn.BaseURL, srv.URL) + } +} + +func TestConnectExplicitTokenPrecedence(t *testing.T) { + // Options.Token beats ?token= in the URL and skips the legacy fallback + // entirely (no cookie, no probe needed). + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/models" { + t.Error("explicit token should skip the legacy auth probe") + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + conn, err := Connect(Options{URL: srv.URL + "/?token=urltok", Token: "explicit"}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + if conn.Token != "explicit" { + t.Errorf("Token = %q, want %q", conn.Token, "explicit") + } +} + +func TestConnectLegacyOldServer(t *testing.T) { + // No cookie and an unprotected API: this odek predates enforced auth, so + // Connect proceeds with an empty token. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + conn, err := Connect(Options{URL: srv.URL}) + if err != nil { + t.Fatalf("Connect: %v", err) + } + if conn.Token != "" { + t.Errorf("Token = %q, want empty for a pre-auth odek", conn.Token) + } +} + +func TestConnectEnforcedAuthError(t *testing.T) { + // No cookie and 403 from the API: auth is enforced, so Connect must fail + // with guidance on how to supply the token. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/models" { + w.WriteHeader(http.StatusForbidden) + return + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + _, err := Connect(Options{URL: srv.URL}) + if err == nil { + t.Fatal("expected Connect to fail when auth is enforced") + } + if !strings.Contains(err.Error(), "requires a WS token") || + !strings.Contains(err.Error(), "--token") { + t.Errorf("error should explain how to pass the token, got: %v", err) + } +} + +func TestSplitTokenURL(t *testing.T) { + cases := []struct{ raw, base, token string }{ + {"http://127.0.0.1:8080/?token=abc", "http://127.0.0.1:8080/", "abc"}, + {"http://127.0.0.1:8080", "http://127.0.0.1:8080", ""}, + {"http://127.0.0.1:8080/?token=a%20b", "http://127.0.0.1:8080/", "a b"}, + } + for _, tc := range cases { + base, token := splitTokenURL(tc.raw) + if base != tc.base || token != tc.token { + t.Errorf("splitTokenURL(%q) = %q, %q; want %q, %q", tc.raw, base, token, tc.base, tc.token) + } + } +} + +func TestParseTokenLine(t *testing.T) { + cases := []struct{ line, want string }{ + {" WS token: cafef00d", "cafef00d"}, + {"odek serve ⚡ http://127.0.0.1:8080/?token=beef", "beef"}, + {"odek serve ⚡ http://127.0.0.1:8080/?token=beef&x=1", "beef"}, + {" WebSocket: ws://127.0.0.1:8080/ws", ""}, + {"random log line", ""}, + } + for _, tc := range cases { + if got := parseTokenLine(tc.line); got != tc.want { + t.Errorf("parseTokenLine(%q) = %q, want %q", tc.line, got, tc.want) + } + } +} + func TestConnectSpawnMissingBinary(t *testing.T) { _, err := Connect(Options{Bin: "definitely-not-a-real-binary-xyz"}) if err == nil { From 76b8799e74b9dad630fdf2334ffa5b95e3cd3ba1 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 25 Jul 2026 16:35:30 +0200 Subject: [PATCH 2/3] fix(tui): polish pass on telemetry, layout, rendering, and glyphs - /clear and ctrl+l now reset session telemetry (turns, tools, tokens, age) so /stats and the header gauge start fresh - keep the viewport height stable when the taller approval panel opens/closes, so the footer never jumps - cache the finalized transcript prefix; re-render only the streaming tail on spinner ticks (invalidate on finalize, resize, resume, clear) - busy refresh no longer yanks the reader out of scrollback - gauge fill glyphs now switch on the same 75%/90% bands as its colors - replace emoji sandbox badge with width-stable monochrome glyphs - live elapsed badge ticks in whole seconds (tenths flickered at 12fps) - guard truncate() against zero/negative budgets on narrow terminals - human() promotes to M at the 999.5k rounding seam - restyle /help as a pre-styled brand card; tint the destructive session-delete hint red; brighten faint body text --- internal/tui/banner.go | 6 +- internal/tui/commands.go | 45 ++++++++++---- internal/tui/commands_test.go | 11 +++- internal/tui/helpers_test.go | 12 ++++ internal/tui/integration_test.go | 91 ++++++++++++++++++++++------ internal/tui/model.go | 67 +++++++++++++++++---- internal/tui/model_smoke_test.go | 29 ++++++++- internal/tui/panels.go | 11 +--- internal/tui/scroll_test.go | 97 ++++++++++++++++++++++++++++++ internal/tui/stats_test.go | 60 ++++++++++++++++++- internal/tui/styles.go | 34 +++++++---- internal/tui/view.go | 100 ++++++++++++++++++++----------- 12 files changed, 458 insertions(+), 105 deletions(-) diff --git a/internal/tui/banner.go b/internal/tui/banner.go index 342166a..0fff394 100644 --- a/internal/tui/banner.go +++ b/internal/tui/banner.go @@ -47,9 +47,9 @@ func welcome(th theme, width int, cwd string) string { {"/ commands", "type / for commands, e.g. /help /sessions /model"}, {"/stats", "session metrics & live context-window gauge"}, {"@ to attach", "attach files, e.g. @main.go"}, - {"⏎ send", "· ^J newline · ^T toggle thinking"}, - {"^L clear", "· ↑/↓ scroll · PgUp/PgDn page · ^C quit"}, - {"approvals", "answer with [a]pprove [d]eny [t]rust"}, + {"⏎ send", "^J newline · ^T toggle thinking"}, + {"^L clear", "↑/↓ scroll · PgUp/PgDn page · ^C quit"}, + {"approvals", "a approve · d deny · t trust"}, } const keyW = 11 for _, t := range tips { diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 8a65630..dac50e4 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -27,9 +27,7 @@ func slashCommands() []command { return nil }}, {"clear", "clear the conversation", func(m *Model, _ string) tea.Cmd { - m.msgs = nil - m.curIdx = -1 - m.refresh() + m.clearConversation() return nil }}, {"stats", "session metrics & context gauge", func(m *Model, _ string) tea.Cmd { @@ -147,18 +145,43 @@ func (m *Model) openCmdAC(query string) { m.refresh() } -// showHelp appends a rendered help card listing commands and key bindings. +// showHelp appends a help card listing commands and key bindings. Like /stats +// it is pre-styled to the brand palette (raw), not glamour's stock dark style. func (m *Model) showHelp() { + th := m.th + // Same box math as the /stats card: Width(boxW-2) renders boxW wide with a + // boxW-4 content column the rules must match. + boxW := max(min(m.vp.Width-2, 60), 28) + innerW := boxW - 4 + rule := "\n" + th.rule.Render(strings.Repeat("─", innerW)) + var b strings.Builder - b.WriteString("### Commands\n\n") + b.WriteString(th.acTitle.Render("⬡ help")) + b.WriteString(rule) + b.WriteString("\n" + th.statsLabel.Render("commands")) + const cmdW = 10 // longest name is "/thinking" for _, c := range slashCommands() { - b.WriteString(fmt.Sprintf("- `/%s` — %s\n", c.name, c.desc)) + b.WriteString("\n" + th.tipKey.Render(padRight("/"+c.name, cmdW)) + " " + th.tipText.Render(c.desc)) } - b.WriteString("\n### Keys\n\n") - b.WriteString("`@` attach files/sessions · `^R` sessions · `^O` model · " + - "`^T` thinking · `^J` newline · `Esc` cancel · `^L` clear · `^C` quit\n") - content := b.String() - m.msgs = append(m.msgs, message{role: roleAsst, content: content, rendered: m.render(content)}) + b.WriteString(rule) + b.WriteString("\n" + th.statsLabel.Render("keys")) + const keyW = 4 + for _, k := range [][2]string{ + {"⏎", "send · run a /command"}, + {"^J", "newline in the input"}, + {"@", "attach files"}, + {"^R", "browse & resume sessions"}, + {"^O", "switch model"}, + {"^T", "toggle extended thinking"}, + {"^L", "clear the conversation"}, + {"esc", "cancel the running turn"}, + {"^C", "quit"}, + } { + b.WriteString("\n" + th.tipKey.Render(padRight(k[0], keyW)) + " " + th.tipText.Render(k[1])) + } + + card := th.acBox.Width(boxW - 2).Render(b.String()) + m.msgs = append(m.msgs, message{role: roleAsst, content: card, rendered: card, raw: true}) m.refresh() } diff --git a/internal/tui/commands_test.go b/internal/tui/commands_test.go index a3bf1cf..385bd24 100644 --- a/internal/tui/commands_test.go +++ b/internal/tui/commands_test.go @@ -36,11 +36,16 @@ func TestSlashCommandsViaSubmit(t *testing.T) { t.Errorf("/clear left %d messages", len(m.msgs)) } - // /help appends a rendered help card; input is reset. + // /help appends a pre-styled help card; input is reset. m.ta.SetValue("/help") exec(m.submit()) - if len(m.msgs) == 0 || !strings.Contains(m.msgs[len(m.msgs)-1].content, "Commands") { - t.Error("/help did not append a help card") + if len(m.msgs) == 0 { + t.Fatal("/help did not append a help card") + } + help := m.msgs[len(m.msgs)-1] + if !help.raw || !strings.Contains(plain(help.content), "commands") || + !strings.Contains(plain(help.content), "/sessions") { + t.Errorf("/help card malformed (raw=%v):\n%s", help.raw, plain(help.content)) } if m.ta.Value() != "" { t.Errorf("input not reset after command: %q", m.ta.Value()) diff --git a/internal/tui/helpers_test.go b/internal/tui/helpers_test.go index 22431ba..def2a7b 100644 --- a/internal/tui/helpers_test.go +++ b/internal/tui/helpers_test.go @@ -25,6 +25,8 @@ func TestHuman(t *testing.T) { {0, "0"}, {42, "42"}, {1234, "1.2k"}, + {999_499, "999.5k"}, // just under the rounding seam + {999_500, "1.0M"}, // one-decimal k rounding would reach 1000.0k → promote {2_500_000, "2.5M"}, } for _, c := range cases { @@ -41,6 +43,16 @@ func TestTruncate(t *testing.T) { if got := truncate("hello world", 5); got != "hell…" { t.Errorf("truncate: got %q", got) } + // A zero/negative budget (very narrow terminal) must not panic. + for _, n := range []int{0, -1, -8} { + if got := truncate("hello", n); got != "" { + t.Errorf("truncate(_, %d) = %q, want empty", n, got) + } + } + // Budget of one leaves room for the ellipsis alone. + if got := truncate("hello", 1); got != "…" { + t.Errorf("truncate(_, 1) = %q, want ellipsis", got) + } } func TestCollapse(t *testing.T) { diff --git a/internal/tui/integration_test.go b/internal/tui/integration_test.go index d6b405b..b2c229c 100644 --- a/internal/tui/integration_test.go +++ b/internal/tui/integration_test.go @@ -2,6 +2,7 @@ package tui import ( "encoding/json" + "errors" "net/http" "net/http/httptest" "strings" @@ -16,24 +17,62 @@ import ( // wired builds a Model backed by a live in-process odek-serve stand-in. func wired(t *testing.T) *Model { + t.Helper() + return standIn(t, "tok") +} + +// standIn builds a Model against an in-process odek-serve stand-in that +// optionally enforces the per-instance WS token (cookie or X-Odek-Ws-Token +// header) on /ws and /api/*, mirroring odek serve; an empty token disables +// enforcement like odek versions that predate enforced auth. +func standIn(t *testing.T, token string) *Model { t.Helper() t.Setenv("HOME", t.TempDir()) - mux := http.NewServeMux() - mux.Handle("/ws", ws.Handler(func(c *ws.Conn) { - for { - var d []byte - if err := ws.Message.Receive(c, &d); err != nil { + // serveTokenOK mirrors odek serve's validateServeToken: the token is + // accepted via the odek_ws_token cookie or the X-Odek-Ws-Token header. + serveTokenOK := func(r *http.Request) bool { + if token == "" { + return true + } + if c, err := r.Cookie("odek_ws_token"); err == nil && c.Value == token { + return true + } + return r.Header.Get("X-Odek-Ws-Token") == token + } + guard := func(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !serveTokenOK(r) { + w.WriteHeader(http.StatusForbidden) return } - _ = ws.JSON.Send(c, map[string]any{"type": "session", "session_id": "s1", "auth_token": "a1", "model": "m"}) - _ = ws.Message.Send(c, `{"type":"done","latency":1}`) + h(w, r) } - })) - mux.HandleFunc("/api/sessions", func(w http.ResponseWriter, r *http.Request) { - json.NewEncoder(w).Encode([]client.Session{{ID: "s1", Task: "first task", Turns: 1, UpdatedAt: time.Now()}}) + } + + mux := http.NewServeMux() + mux.Handle("/ws", ws.Server{ + Handshake: func(cfg *ws.Config, r *http.Request) error { + if !serveTokenOK(r) { + return errors.New("missing or invalid server token") + } + return nil + }, + Handler: ws.Handler(func(c *ws.Conn) { + for { + var d []byte + if err := ws.Message.Receive(c, &d); err != nil { + return + } + _ = ws.JSON.Send(c, map[string]any{"type": "session", "session_id": "s1", "auth_token": "a1", "model": "m"}) + _ = ws.Message.Send(c, `{"type":"done","latency":1}`) + } + }), }) - mux.HandleFunc("/api/sessions/", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/api/sessions", guard(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]client.Session{{ID: "s1", Task: "first task", Turns: 1, UpdatedAt: time.Now()}}) + })) + mux.HandleFunc("/api/sessions/", guard(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodDelete { w.WriteHeader(http.StatusNoContent) return @@ -47,20 +86,20 @@ func wired(t *testing.T) *Model { {Role: "assistant", Content: ""}, // skipped (empty) }, }) - }) - mux.HandleFunc("/api/models", func(w http.ResponseWriter, r *http.Request) { + })) + mux.HandleFunc("/api/models", guard(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode([]client.ModelInfo{{ID: "m1", Description: "one", Current: true}}) - }) - mux.HandleFunc("/api/resources", func(w http.ResponseWriter, r *http.Request) { + })) + mux.HandleFunc("/api/resources", guard(func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode([]client.Resource{{ID: "@main.go", Type: "file", Label: "main.go", Detail: "1 KB"}}) - }) - mux.HandleFunc("/api/cancel", func(w http.ResponseWriter, r *http.Request) { + })) + mux.HandleFunc("/api/cancel", guard(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) - }) + })) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - cl, err := client.Dial("ws"+strings.TrimPrefix(srv.URL, "http")+"/ws", srv.URL, srv.URL, "tok") + cl, err := client.Dial("ws"+strings.TrimPrefix(srv.URL, "http")+"/ws", srv.URL, srv.URL, token) if err != nil { t.Fatalf("Dial: %v", err) } @@ -71,6 +110,16 @@ func wired(t *testing.T) *Model { return m } +func TestStandInWithoutTokenEnforcement(t *testing.T) { + // Empty token = an odek that predates enforced auth: the stand-in serves + // /ws and /api/* without any token checks. + m := standIn(t, "") + m.Update(exec(m.openModels())) + if len(m.models) != 1 { + t.Fatalf("models against an unenforced stand-in: %d", len(m.models)) + } +} + func exec(cmd tea.Cmd) tea.Msg { if cmd == nil { return nil @@ -357,4 +406,8 @@ func TestElapsed(t *testing.T) { if m.elapsed() == "" { t.Error("elapsed should be non-empty during a run") } + // The live badge ticks in whole seconds (tenths would flicker at 12fps). + if strings.Contains(m.elapsed(), ".") { + t.Errorf("elapsed should not show tenths: %q", m.elapsed()) + } } diff --git a/internal/tui/model.go b/internal/tui/model.go index 74d38c8..7c70b3c 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -126,6 +126,10 @@ type Model struct { gradRule string // cached full-width gradient rule gradRuleW int + logoCache string // cached gradient logo (width-independent) + + convPrefix string // cached rendering of the finalized transcript prefix + convCount int // messages the prefix covers (-1 = invalidated) } // acMode selects what the completion popup is completing. @@ -362,8 +366,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "ctrl+l": if !m.busy { - m.msgs = nil - m.refresh() + m.clearConversation() } return m, nil case "up", "ctrl+p": @@ -490,6 +493,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { e := ev m.approval = &e m.status = "approval required" + m.relayout() // the panel is taller than the textarea — shrink the viewport case "skill_event": m.addNote("skill · " + strings.TrimSpace(ev.SubType+" "+ev.SkillName) + eventTail(ev)) @@ -528,6 +532,23 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { // ── actions ────────────────────────────────────────────────────────────── +// clearConversation wipes the transcript and the session-scoped telemetry, so +// /stats, the header gauge, and the footer start fresh after a clear instead +// of reporting turns, tools, tokens, and age from before it (mirrors the +// reset done when resuming a session). +func (m *Model) clearConversation() { + m.msgs = nil + m.curIdx = -1 + m.convCount = -1 // transcript replaced — drop the cached prefix + m.turnStats = nil + m.toolTotal = 0 + m.sessionStart = time.Time{} + m.sessCtxTok = 0 + m.sessOutTok = 0 + m.lastLatency = 0 + m.refresh() +} + func (m *Model) submit() tea.Cmd { text := strings.TrimSpace(m.ta.Value()) if text == "" { @@ -578,6 +599,7 @@ func (m *Model) answer(action string) tea.Cmd { id := m.approval.ID m.approval = nil m.status = "thinking" + m.relayout() m.refresh() cl := m.cl return func() tea.Msg { @@ -691,7 +713,8 @@ func (m *Model) resize(w, h int) tea.Cmd { m.ready = true } m.ta.SetWidth(w - 4) - m.gradRule = "" // invalidate cached rule for the new width + m.gradRule = "" // invalidate cached rule for the new width + m.convCount = -1 // and the cached transcript prefix (bars are width-dependent) m.relayout() wrap := w - 6 @@ -717,16 +740,12 @@ func (m *Model) resize(w, h int) tea.Cmd { } // relayout recomputes the viewport height from the current chrome, accounting -// for the @-reference popup when it is open. +// for the approval panel or the @-reference popup when either is open. func (m *Model) relayout() { if !m.ready { return } - inputH := inputHeight - if m.ac.open { - inputH += m.ac.height() - } - vpH := m.height - headerHeight - footerHeight - inputH + vpH := m.height - headerHeight - footerHeight - m.inputAreaHeight() if vpH < 3 { vpH = 3 } @@ -734,6 +753,23 @@ func (m *Model) relayout() { m.vp.Height = vpH } +// inputAreaHeight is the number of rows the input area renders, so the +// viewport shrinks by exactly the right amount and the footer never moves. +func (m *Model) inputAreaHeight() int { + if m.approval != nil { + rows := 3 // head + command + keys + if m.approval.Description != "" { + rows++ + } + return rows + 2 // panel border + } + h := inputHeight + if m.ac.open { + h += m.ac.height() + } + return h +} + // ── @-reference autocomplete ──────────────────────────────────────────────── // refRe matches a trailing @-reference token at the end of the input. @@ -843,12 +879,18 @@ func (m *Model) closeAC() { m.refresh() } -// elapsed formats the current run's wall-clock time, e.g. "3.2s". +// elapsed formats the current run's wall-clock time in whole seconds (the live +// badge re-renders with every spinner tick, so tenths would flicker). The +// finalized per-turn stat line keeps tenths via formatDuration. func (m *Model) elapsed() string { if m.runStart.IsZero() { return "" } - return formatDuration(time.Since(m.runStart)) + d := time.Since(m.runStart) + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60) } // argPreview extracts a short, human-friendly summary from a tool's JSON args. @@ -1006,6 +1048,9 @@ func formatDuration(d time.Duration) string { } func truncate(s string, n int) string { + if n < 1 { + return "" // no room even for the ellipsis (very narrow terminal) + } r := []rune(s) if len(r) <= n { return s diff --git a/internal/tui/model_smoke_test.go b/internal/tui/model_smoke_test.go index 2d563d7..4d1a7aa 100644 --- a/internal/tui/model_smoke_test.go +++ b/internal/tui/model_smoke_test.go @@ -22,9 +22,11 @@ func plain(s string) string { return ansiRe.ReplaceAllString(s, "") } // newTestModel builds a Model without a live client/TTY for rendering tests. func newTestModel() *Model { + ta := textarea.New() + ta.SetHeight(3) // match New(), so inputHeight row math holds m := &Model{ th: newTheme(), - ta: textarea.New(), + ta: ta, sp: spinner.New(), curIdx: -1, status: "ready", @@ -105,3 +107,28 @@ func TestWindowSizeMsg(t *testing.T) { t.Error("model not ready after WindowSizeMsg") } } + +// TestApprovalPanelLayoutStable verifies the screen does not grow a row when +// the approval panel (taller than the textarea it replaces) opens or closes. +func TestApprovalPanelLayoutStable(t *testing.T) { + m := newTestModel() + height := func() int { return strings.Count(m.View(), "\n") + 1 } + base := height() + + // A description pushes the panel to 6 rendered rows; relayout must shrink + // the viewport to match so the footer stays put. + m.handleEvent(client.Event{Type: "approval_request", Risk: "shell_exec", + Name: "shell", Command: "rm x", Description: "delete files", AllowTrust: true}) + if got := height(); got != base { + t.Errorf("view height changed when approval opened: %d → %d rows", base, got) + } + m.answer("approve") + if got := height(); got != base { + t.Errorf("view height changed when approval closed: %d → %d rows", base, got) + } + + // Very narrow terminal: truncate budgets go ≤ 0 — must not panic. + m.resize(6, 12) + m.handleEvent(client.Event{Type: "approval_request", Name: "shell", Command: "rm x"}) + _ = m.View() +} diff --git a/internal/tui/panels.go b/internal/tui/panels.go index c68208b..b680941 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -240,6 +240,7 @@ func (m *Model) handleSessionDetail(msg sessionDetailMsg) { m.sessOutTok = 0 m.lastLatency = 0 m.msgs = m.msgs[:0] + m.convCount = -1 // transcript swapped for the resumed one — drop the cache for _, mm := range msg.sess.Messages { // Persisted transcripts are attacker-influenced (agent output, and the // session file itself); strip terminal control sequences before display. @@ -309,14 +310,8 @@ func (m *Model) renderPanel(w, h int) string { body += "\n" + strings.Join(windowRows(rows, m.panelSel, visible), "\n") } - box := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(colBrand). - Padding(0, 1). - Width(w - 2). - Height(h - 2). - Render(body) - return box + // acBox is exactly the rounded brand box this panel used to hand-build. + return th.acBox.Width(w - 2).Height(h - 2).Render(body) } func (m *Model) sessionRows(w int) []string { diff --git a/internal/tui/scroll_test.go b/internal/tui/scroll_test.go index 9268d34..9a21830 100644 --- a/internal/tui/scroll_test.go +++ b/internal/tui/scroll_test.go @@ -3,8 +3,12 @@ package tui import ( "strings" "testing" + "time" tea "github.com/charmbracelet/bubbletea" + + "github.com/BackendStack21/bodek/internal/client" + "github.com/BackendStack21/bodek/internal/tokens" ) // TestUpDownScrollTranscript verifies that ↑/↓ scroll the conversation when @@ -104,3 +108,96 @@ func TestMouseWheelScrollsTranscript(t *testing.T) { t.Errorf("mouse wheel down did not return to bottom: yoffset=%d", m.vp.YOffset) } } + +// TestBusyRefreshKeepsScrollback verifies that a run in progress does not yank +// the reader to the bottom on every refresh: autoscroll only sticks when the +// viewport is already at the bottom. +func TestBusyRefreshKeepsScrollback(t *testing.T) { + m := newTestModel() + + md := strings.Repeat("transcript line\n", 60) + m.msgs = append(m.msgs, + message{role: roleUser, content: "q"}, + message{role: roleAsst, content: md, rendered: m.render(md)}, + ) + m.refresh() + if !m.vp.AtBottom() { + t.Fatal("precondition: transcript should start at the bottom") + } + + // Scroll up, then stream a turn: refreshes must leave the position alone. + m.vp.ScrollUp(5) + top := m.vp.YOffset + m.msgs = append(m.msgs, message{role: roleAsst, streaming: true}) + m.curIdx = len(m.msgs) - 1 + m.busy = true + m.runStart = time.Now() + m.handleEvent(client.Event{Type: "token", Content: "streaming…"}) + if m.vp.YOffset != top { + t.Errorf("busy refresh yanked scrollback: yoffset %d → %d", top, m.vp.YOffset) + } + + // Once back at the bottom, the reader follows the stream again. + m.vp.GotoBottom() + m.handleEvent(client.Event{Type: "token", Content: "more"}) + if !m.vp.AtBottom() { + t.Error("at-bottom reader should follow the stream") + } +} + +// TestTranscriptPrefixCached verifies the finalized transcript prefix renders +// once and is reused across streaming ticks, and that the cache invalidates on +// finalize, resize, and wholesale transcript replacement (session resume). +func TestTranscriptPrefixCached(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + m := newTestModel() + m.tokens = tokens.Open() + m.msgs = append(m.msgs, + message{role: roleUser, content: "q"}, + message{role: roleAsst, content: "answer", streaming: true}, + ) + m.curIdx = 1 + m.busy = true + + m.refresh() + if m.convCount != 1 || m.convPrefix == "" { + t.Fatalf("prefix not cached: count=%d", m.convCount) + } + prefix := m.convPrefix + // A streaming tick re-renders only the tail; the prefix is untouched. + m.handleEvent(client.Event{Type: "token", Content: "…"}) + if m.convPrefix != prefix { + t.Error("streaming tick rebuilt the finalized prefix") + } + + // Finalizing extends the prefix to cover the whole transcript. + m.handleEvent(client.Event{Type: "done"}) + if m.convCount != len(m.msgs) { + t.Errorf("after finalize convCount=%d, want %d", m.convCount, len(m.msgs)) + } + if !strings.Contains(plain(m.convPrefix), "answer") { + t.Error("finalized prefix missing the answer") + } + + // Resize invalidates (the message bars are width-dependent) and rebuilds. + wide := m.convPrefix + m.resize(80, 30) + if m.convPrefix == wide { + t.Error("resize did not rebuild the prefix at the new width") + } + if m.convCount != len(m.msgs) || !strings.Contains(plain(m.convPrefix), "answer") { + t.Error("prefix not rebuilt after resize") + } + + // Resuming a session swaps the transcript (same length here, which a pure + // count check could not catch) — the stale prefix must not be served. + m.handleSessionDetail(sessionDetailMsg{sess: client.Session{ID: "s2", + Messages: []client.SessionMessage{ + {Role: "user", Content: "q"}, + {Role: "assistant", Content: "resumed reply"}, + }}}) + if strings.Contains(plain(m.convPrefix), "answer") || + !strings.Contains(plain(m.convPrefix), "resumed reply") { + t.Errorf("stale prefix after resume: %q", plain(m.convPrefix)) + } +} diff --git a/internal/tui/stats_test.go b/internal/tui/stats_test.go index eb91951..b67d04b 100644 --- a/internal/tui/stats_test.go +++ b/internal/tui/stats_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/BackendStack21/bodek/internal/client" @@ -88,12 +89,20 @@ func TestContextGauge(t *testing.T) { m.sessCtxTok = 380 out := plain(m.header()) - for _, want := range []string{"◑", "38%", "380/1k"} { + for _, want := range []string{"○", "38%", "380/1k"} { if !strings.Contains(out, want) { t.Errorf("header gauge missing %q in:\n%s", want, out) } } + // Fill glyph and color band move together: half past 75%, full past 90%. + if g := gaugeGlyph(0.80); g != "◐" { + t.Errorf("gaugeGlyph(0.80) = %q, want ◐", g) + } + if g := gaugeGlyph(0.95); g != "●" { + t.Errorf("gaugeGlyph(0.95) = %q, want ●", g) + } + // Unknown budget hides the gauge entirely (no percent sign in the header). m.maxContext = 0 if strings.Contains(plain(m.header()), "%") { @@ -242,3 +251,52 @@ func TestSessionResumeResetsTelemetry(t *testing.T) { m.sessCtxTok, m.sessOutTok, m.lastLatency) } } + +// Clearing the conversation (/clear or ctrl+l) must also reset the session +// telemetry, so the stats UI doesn't keep showing pre-clear turns, tools, +// tokens, and age. +func TestClearResetsTelemetry(t *testing.T) { + clears := map[string]func(m *Model){ + "/clear": func(m *Model) { + for _, c := range slashCommands() { + if c.name == "clear" { + c.run(m, "") + } + } + }, + "ctrl+l": func(m *Model) { + m.Update(tea.KeyMsg{Type: tea.KeyCtrlL}) + }, + } + for name, clear := range clears { + t.Run(name, func(t *testing.T) { + m := driveTurn(t, client.Event{ + Type: "done", Latency: 2.5, + ContextTokens: 1200, OutputTokens: 340, + SessionContextTokens: 1200, SessionOutputTokens: 340, + }) + if len(m.turnStats) == 0 || m.toolTotal == 0 || m.sessCtxTok == 0 { + t.Fatal("precondition: session telemetry not populated by the turn") + } + + clear(m) + + if len(m.msgs) != 0 { + t.Errorf("msgs not cleared: %d", len(m.msgs)) + } + if len(m.turnStats) != 0 { + t.Errorf("turnStats not reset: %d", len(m.turnStats)) + } + if m.toolTotal != 0 { + t.Errorf("toolTotal not reset: %d", m.toolTotal) + } + if !m.sessionStart.IsZero() { + t.Error("sessionStart not reset") + } + if m.sessCtxTok != 0 || m.sessOutTok != 0 || m.lastLatency != 0 { + t.Errorf("session token/latency not reset: ctx=%d out=%d lat=%v", + m.sessCtxTok, m.sessOutTok, m.lastLatency) + } + }) + } +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 2734bdf..2b41813 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -18,7 +18,8 @@ var ( colRed = lipgloss.Color("#F87171") colFg = lipgloss.Color("#E5E7EB") colMuted = lipgloss.Color("#9CA3AF") - colFaint = lipgloss.Color("#6B7280") + colFaint = lipgloss.Color("#6B7280") // chrome only — too dim for body text + colBodyText = lipgloss.Color("#8B93A3") // brighter faint for content text colHairline = lipgloss.Color("#3B3B4F") ) @@ -40,7 +41,6 @@ type theme struct { userBar lipgloss.Style asstLabel lipgloss.Style asstBar lipgloss.Style - sysLabel lipgloss.Style sysBar lipgloss.Style stepName lipgloss.Style @@ -67,6 +67,11 @@ type theme struct { statusReady lipgloss.Style statusBusy lipgloss.Style + // header/status badges (sandbox state, connectivity) + badgeOK lipgloss.Style + badgeWarn lipgloss.Style + badgeDanger lipgloss.Style + toolIcon lipgloss.Style scroll lipgloss.Style @@ -90,9 +95,10 @@ type theme struct { opChip lipgloss.Style untrustedTag lipgloss.Style - footer lipgloss.Style - footerKey lipgloss.Style - footerSep lipgloss.Style + footer lipgloss.Style + footerKey lipgloss.Style + footerSep lipgloss.Style + footerDanger lipgloss.Style tagline lipgloss.Style tipKey lipgloss.Style @@ -118,7 +124,6 @@ func newTheme() theme { userBar: lipgloss.NewStyle().Foreground(colFg).Border(lipgloss.ThickBorder(), false, false, false, true).BorderForeground(colBrand2).PaddingLeft(1), asstLabel: lipgloss.NewStyle().Foreground(colBrand).Bold(true), asstBar: lipgloss.NewStyle().Border(lipgloss.ThickBorder(), false, false, false, true).BorderForeground(colBrand).PaddingLeft(1), - sysLabel: lipgloss.NewStyle().Foreground(colRed).Bold(true), sysBar: lipgloss.NewStyle().Foreground(colRed).Border(lipgloss.ThickBorder(), false, false, false, true).BorderForeground(colRed).PaddingLeft(1), stepName: lipgloss.NewStyle().Foreground(colCyan), @@ -126,7 +131,7 @@ func newTheme() theme { stepRun: lipgloss.NewStyle().Foreground(colYellow), stepDone: lipgloss.NewStyle().Foreground(colGreen), stepErr: lipgloss.NewStyle().Foreground(colRed).Bold(true), - stepRes: lipgloss.NewStyle().Foreground(colFaint).Italic(true), + stepRes: lipgloss.NewStyle().Foreground(colBodyText).Italic(true), stepTree: lipgloss.NewStyle().Foreground(colHairline), spinner: lipgloss.NewStyle().Foreground(colBrand), @@ -134,8 +139,8 @@ func newTheme() theme { taCursorLine: lipgloss.NewStyle(), inputBox: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(colHairline).Padding(0, 1), - noticeStyle: lipgloss.NewStyle().Foreground(colFaint).Italic(true), - thinkStyle: lipgloss.NewStyle().Foreground(colFaint).Italic(true), + noticeStyle: lipgloss.NewStyle().Foreground(colBodyText).Italic(true), + thinkStyle: lipgloss.NewStyle().Foreground(colBodyText).Italic(true), apprBox: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(colYellow).Padding(0, 1), apprHead: lipgloss.NewStyle().Foreground(colYellow).Bold(true), @@ -145,6 +150,10 @@ func newTheme() theme { statusReady: lipgloss.NewStyle().Foreground(colGreen), statusBusy: lipgloss.NewStyle().Foreground(colYellow), + badgeOK: lipgloss.NewStyle().Foreground(colGreen), + badgeWarn: lipgloss.NewStyle().Foreground(colYellow), + badgeDanger: lipgloss.NewStyle().Foreground(colRed), + toolIcon: lipgloss.NewStyle().Foreground(colCyan), scroll: lipgloss.NewStyle().Foreground(colFaint), @@ -169,9 +178,10 @@ func newTheme() theme { opChip: lipgloss.NewStyle().Foreground(colCyan), untrustedTag: lipgloss.NewStyle().Foreground(colYellow), - footer: lipgloss.NewStyle().Foreground(colFaint), - footerKey: lipgloss.NewStyle().Foreground(colMuted).Bold(true), - footerSep: lipgloss.NewStyle().Foreground(colHairline), + footer: lipgloss.NewStyle().Foreground(colFaint), + footerKey: lipgloss.NewStyle().Foreground(colMuted).Bold(true), + footerSep: lipgloss.NewStyle().Foreground(colHairline), + footerDanger: lipgloss.NewStyle().Foreground(colRed), tagline: lipgloss.NewStyle().Foreground(colMuted).Italic(true), tipKey: lipgloss.NewStyle().Foreground(colCyan).Bold(true), diff --git a/internal/tui/view.go b/internal/tui/view.go index bc7f6ee..40aa62e 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -31,7 +31,12 @@ func (m *Model) View() string { func (m *Model) header() string { th := m.th - logo := th.logo.Render(gradient("⬡ bodek", gradFrom, gradTo)) + // The logo gradient is width-independent, so render it once and cache it + // (like gradRule) instead of re-interpolating every frame. + if m.logoCache == "" { + m.logoCache = th.logo.Render(gradient("⬡ bodek", gradFrom, gradTo)) + } + logo := m.logoCache think := "off" if m.thinkOn { @@ -41,8 +46,8 @@ func (m *Model) header() string { if modelName == "" { modelName = "default" } - // Sandbox status, prominently colored: green shield when isolated, amber - // warning when the agent has host access. + // Sandbox status, prominently colored: green ● when isolated, amber ▲ + // when the agent has host access. sandbox := m.sandboxBadge() meta := th.headerMeta.Render(" · think ") + th.headerKey.Render(think) model := th.headerKey.Render(modelName) @@ -118,27 +123,25 @@ func (m *Model) gaugeColor(ratio float64) lipgloss.Style { } } -// sandboxBadge renders the agent's isolation state: a green shield when -// sandboxed, an amber warning when it has host access. Shared by the header and -// the /stats card so the two never drift. +// sandboxBadge renders the agent's isolation state with the monochrome glyph +// vocabulary (width-stable, unlike emoji): a green ● when sandboxed, an amber ▲ +// when it has host access. Shared by the header and the /stats card so the two +// never drift. func (m *Model) sandboxBadge() string { if m.sandbox { - return lipgloss.NewStyle().Foreground(colGreen).Render("🛡 sandboxed") + return m.th.badgeOK.Render("● sandboxed") } - return lipgloss.NewStyle().Foreground(colYellow).Render("⚠ host access") + return m.th.badgeWarn.Render("▲ host access") } -// gaugeGlyph quantizes a fill ratio into a five-step circle. +// gaugeGlyph mirrors the gaugeColor bands (0.75 / 0.90) so fill and hue tell +// the same story: open while comfortable, half when warm, full when hot. func gaugeGlyph(r float64) string { switch { - case r >= 0.87: + case r >= 0.90: return "●" - case r >= 0.62: - return "◕" - case r >= 0.37: - return "◑" - case r >= 0.12: - return "◔" + case r >= 0.75: + return "◐" default: return "○" } @@ -158,7 +161,7 @@ func (m *Model) statusBadge() string { th := m.th switch { case m.disconn: - return lipgloss.NewStyle().Foreground(colRed).Render("● disconnected") + return th.badgeDanger.Render("● disconnected") case m.approval != nil: return th.statusBusy.Render("⚠ approval required") case m.busy: @@ -185,14 +188,14 @@ func (m *Model) statusBadge() string { // ── transcript ─────────────────────────────────────────────────────────── -// refresh rebuilds the viewport content and scrolls to the latest output. -// While busy it follows the stream; when idle it preserves the reader's -// position unless they were already at the bottom. +// refresh rebuilds the viewport content and scrolls to the latest output only +// when the reader is already at the bottom — a run in progress must not yank +// them away from scrollback they are reading. func (m *Model) refresh() { if !m.ready { return } - stick := m.busy || m.vp.AtBottom() + stick := m.vp.AtBottom() m.vp.SetContent(m.conversation()) if stick { m.vp.GotoBottom() @@ -203,8 +206,28 @@ func (m *Model) conversation() string { if len(m.msgs) == 0 { return welcome(m.th, m.vp.Width, m.opts.CWD) } - blocks := make([]string, 0, len(m.msgs)+1) - for i := range m.msgs { + // Everything before the in-flight streaming message is stable, so cache its + // rendering (convPrefix) and re-render only the tail — a spinner tick would + // otherwise re-style every finalized message each frame. The cache is + // invalidated when the message list is replaced wholesale (clear, resume) + // or re-rendered (resize). + tail := len(m.msgs) + if i := m.cur(); i >= 0 { + tail = i + } + if m.convCount != tail { + blocks := make([]string, 0, tail) + for i := 0; i < tail; i++ { + blocks = append(blocks, m.renderMessage(m.msgs[i])) + } + m.convPrefix = strings.Join(blocks, "\n\n") + m.convCount = tail + } + blocks := make([]string, 0, len(m.msgs)-tail+2) + if m.convPrefix != "" { + blocks = append(blocks, m.convPrefix) + } + for i := tail; i < len(m.msgs); i++ { blocks = append(blocks, m.renderMessage(m.msgs[i])) } // Live reasoning for the in-flight turn, shown dimly under the steps. @@ -519,10 +542,10 @@ func (m *Model) approvalPanel() string { desc = th.noticeStyle.Render(truncate(collapse(a.Description), m.width-8)) + "\n" } - keys := th.apprKey.Render("[a]") + th.apprBody.Render(" approve ") + - th.apprKey.Render("[d]") + th.apprBody.Render(" deny") + keys := th.apprKey.Render("a") + th.apprBody.Render(" approve ") + + th.apprKey.Render("d") + th.apprBody.Render(" deny") if a.AllowTrust { - keys += th.apprKey.Render(" [t]") + th.apprBody.Render(" trust class") + keys += th.apprBody.Render(" ") + th.apprKey.Render("t") + th.apprBody.Render(" trust class") } body := head + "\n" + cmd + "\n" + desc + keys @@ -540,10 +563,19 @@ func (m *Model) footer() string { return th.footer.Render(" connection closed · press ^C to quit") } if m.panel == panelSessions { - return m.panelFooter("↑↓ select", "⏎ resume", "d delete", "esc close") + return m.panelFooter( + th.footer.Render("↑↓ select"), + th.footer.Render("⏎ resume"), + th.footerDanger.Render("d delete"), // destructive — tinted to telegraph it + th.footer.Render("esc close"), + ) } if m.panel == panelModels { - return m.panelFooter("↑↓ select", "⏎ use", "esc close") + return m.panelFooter( + th.footer.Render("↑↓ select"), + th.footer.Render("⏎ use"), + th.footer.Render("esc close"), + ) } // The status bar carries no static key cheatsheet (the welcome splash and // /help cover that) — only the live run state: a cancel hint while busy on @@ -576,14 +608,10 @@ func (m *Model) footer() string { return left + strings.Repeat(" ", gap) + right } -// panelFooter renders a simple hint line for an open panel. +// panelFooter joins pre-styled hint segments for an open panel (pre-styled so +// destructive hints can carry the danger tint). func (m *Model) panelFooter(hints ...string) string { - th := m.th - parts := make([]string, len(hints)) - for i, h := range hints { - parts[i] = th.footer.Render(h) - } - return " " + strings.Join(parts, th.footerSep.Render(" · ")) + return " " + strings.Join(hints, m.th.footerSep.Render(" · ")) } // ── small helpers ────────────────────────────────────────────────────────── @@ -598,7 +626,7 @@ func orDash(s string) string { // human formats a token count compactly (e.g. 1234 → "1.2k"). func human(n int) string { switch { - case n >= 1_000_000: + case n >= 999_500: // promote to M once one-decimal k rounding would reach 1000.0k return fmt.Sprintf("%.1fM", float64(n)/1_000_000) case n >= 1_000: return fmt.Sprintf("%.1fk", float64(n)/1_000) From bbe59df4d6cc2d25db41d7aa209b65c609449841 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 25 Jul 2026 16:56:59 +0200 Subject: [PATCH 3/3] test: raise internal-package coverage to 99.8% Cover the remaining gaps across server, tokens, and tui: - server: Connect attach-not-ready and port-allocation errors, spawn start failure, Stop's kill fallback for SIGINT-ignoring children (extracts the hardcoded 8s kill wait into a stopTimeout var, mirroring readyTimeout), splitTokenURL's unparseable-URL return, legacyToken's probe error, and freePort under fd starvation (unix-only test) - tokens: concurrent Set/Get/Delete smoke test under -race - tui: shortenHome arms, welcome cwd line, resource glyphs, /stats conditional arms, esc/^R/^O keys, error-event fallback, disconnect log-path hint, thinking-enabled submit, step-glyph dedup/cap, fetchModels cmd, relayout-not-ready, syncAC query reuse and 6-file trim, elapsed minutes, multibyte capThinking, ctxGauge clamps, gaugeColor bands, acPopup cmd mode/narrow floor, footer gap floor The three remaining uncovered blocks are verified-unreachable defensive branches (json.MarshalIndent of map[string]string, glamour Render with its fixed config, and a pad guard that cannot fire by construction). Also gitignore the coverage.out written by `make cover`. --- .gitignore | 1 + internal/server/server.go | 6 +- internal/server/server_internal_test.go | 58 ++++ internal/server/server_test.go | 9 + internal/server/server_unix_test.go | 55 ++++ internal/tokens/tokens_test.go | 20 ++ internal/tui/last_gaps_test.go | 356 ++++++++++++++++++++++++ 7 files changed, 504 insertions(+), 1 deletion(-) create mode 100644 internal/server/server_unix_test.go create mode 100644 internal/tui/last_gaps_test.go diff --git a/.gitignore b/.gitignore index 7a52a82..28c12ea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Build artifacts /bin/ /bodek +/coverage.out # Editor / OS noise .DS_Store diff --git a/internal/server/server.go b/internal/server/server.go index fb2438e..81a21da 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -24,6 +24,10 @@ const wsTokenCookie = "odek_ws_token" // variable so tests can shorten it. var readyTimeout = 30 * time.Second +// stopTimeout bounds how long Stop waits for the spawned server's graceful +// shutdown before killing it. It is a variable so tests can shorten it. +var stopTimeout = 8 * time.Second + // Conn holds everything needed to talk to an odek serve instance. type Conn struct { BaseURL string // http://127.0.0.1:port @@ -162,7 +166,7 @@ func (c *Conn) Stop() { go func() { _ = c.proc.Wait(); close(done) }() select { case <-done: - case <-time.After(8 * time.Second): + case <-time.After(stopTimeout): _ = c.proc.Process.Kill() } } diff --git a/internal/server/server_internal_test.go b/internal/server/server_internal_test.go index 7da749e..f332aee 100644 --- a/internal/server/server_internal_test.go +++ b/internal/server/server_internal_test.go @@ -63,6 +63,64 @@ func TestSpawnDefaultBinMissing(t *testing.T) { } } +func TestConnectURLNotReady(t *testing.T) { + // Attaching to a URL whose server never answers must fail after the ready + // timeout instead of hanging. + old := readyTimeout + readyTimeout = 250 * time.Millisecond + defer func() { readyTimeout = old }() + + if _, err := Connect(Options{URL: "http://127.0.0.1:1"}); err == nil { + t.Error("expected Connect to fail when the attached server is not ready") + } +} + +func TestSpawnStartError(t *testing.T) { + // A binary that passes LookPath but cannot be exec'd (its shebang points + // at a nonexistent interpreter) must surface the cmd.Start error. + bin := filepath.Join(t.TempDir(), "odek-fake") + if err := os.WriteFile(bin, []byte("#!/nonexistent-interpreter-xyz\n"), 0o755); err != nil { + t.Fatalf("write fake odek: %v", err) + } + c := &Conn{} + if err := c.spawn(Options{Bin: bin}, "127.0.0.1:0"); err == nil { + t.Error("expected spawn to fail when the binary cannot be started") + } +} + +func TestStopKillsLingeringProcess(t *testing.T) { + // A server that ignores SIGINT must be force-killed once stopTimeout + // elapses. `exec` preserves the ignored-signal disposition, so the sleep + // itself ignores SIGINT and Stop falls back to Kill. + sh, err := exec.LookPath("sh") + if err != nil { + t.Skip("no 'sh' binary available") + } + old := stopTimeout + stopTimeout = 200 * time.Millisecond + defer func() { stopTimeout = old }() + + // The "ready" line guarantees the trap is installed before SIGINT is sent. + c := &Conn{proc: exec.Command(sh, "-c", "trap '' INT; echo ready; exec sleep 30")} + stdout, err := c.proc.StdoutPipe() + if err != nil { + t.Fatalf("stdout pipe: %v", err) + } + if err := c.proc.Start(); err != nil { + t.Fatalf("start: %v", err) + } + if _, err := io.ReadFull(stdout, make([]byte, len("ready\n"))); err != nil { + t.Fatalf("read readiness line: %v", err) + } + done := make(chan struct{}) + go func() { c.Stop(); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("Stop did not fall back to Kill for a lingering process") + } +} + func TestConnectSpawnNotReady(t *testing.T) { bin, err := exec.LookPath("sleep") if err != nil { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 7ee731a..7c34b3c 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -63,6 +63,14 @@ func TestFetchTokenRequestError(t *testing.T) { } } +func TestLegacyTokenProbeError(t *testing.T) { + // Unreachable server: the cookie fetch fails and the auth-enforcement + // probe fails too, so legacyToken returns a hard error. + if _, err := legacyToken("http://127.0.0.1:1"); err == nil { + t.Error("expected legacyToken probe error to unreachable host") + } +} + func TestConnectViaURL(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{Name: wsTokenCookie, Value: "tok"}) @@ -171,6 +179,7 @@ func TestSplitTokenURL(t *testing.T) { {"http://127.0.0.1:8080/?token=abc", "http://127.0.0.1:8080/", "abc"}, {"http://127.0.0.1:8080", "http://127.0.0.1:8080", ""}, {"http://127.0.0.1:8080/?token=a%20b", "http://127.0.0.1:8080/", "a b"}, + {"\x7f", "\x7f", ""}, // unparseable URL is returned as-is, without a token } for _, tc := range cases { base, token := splitTokenURL(tc.raw) diff --git a/internal/server/server_unix_test.go b/internal/server/server_unix_test.go new file mode 100644 index 0000000..33390af --- /dev/null +++ b/internal/server/server_unix_test.go @@ -0,0 +1,55 @@ +//go:build unix + +package server + +import ( + "net" + "strings" + "syscall" + "testing" +) + +// TestFreePortFdStarvation exercises the freePort error path — and Connect's +// "allocate port" branch on top of it — by temporarily clamping RLIMIT_NOFILE +// so that no new file descriptor can be opened. The soft limit is lowered and +// then raised back before the test returns, so no other test is affected. +func TestFreePortFdStarvation(t *testing.T) { + // Force netpoller initialization before clamping: it needs a kqueue/epoll + // descriptor of its own, and creating one under the clamp is a fatal + // runtime error instead of a clean Listen error. + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("warmup listen: %v", err) + } + _ = l.Close() + + // New descriptors are always allocated at the lowest free number, and + // RLIMIT_NOFILE caps exactly that number — clamping the soft limit to the + // lowest free descriptor makes every subsequent open fail with EMFILE. + lowest := 0 + var st syscall.Stat_t + for lowest < 4096 && syscall.Fstat(lowest, &st) == nil { + lowest++ + } + var lim syscall.Rlimit + if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &lim); err != nil { + t.Fatalf("getrlimit: %v", err) + } + restore := lim + lim.Cur = uint64(lowest) + if err := syscall.Setrlimit(syscall.RLIMIT_NOFILE, &lim); err != nil { + t.Fatalf("setrlimit: %v", err) + } + defer func() { + if err := syscall.Setrlimit(syscall.RLIMIT_NOFILE, &restore); err != nil { + t.Errorf("restore rlimit: %v", err) + } + }() + + if _, err := freePort(); err == nil { + t.Error("expected freePort to fail with no descriptors available") + } + if _, err := Connect(Options{}); err == nil || !strings.Contains(err.Error(), "allocate port") { + t.Errorf("Connect = %v, want allocate-port error", err) + } +} diff --git a/internal/tokens/tokens_test.go b/internal/tokens/tokens_test.go index 3511de7..d9c5f85 100644 --- a/internal/tokens/tokens_test.go +++ b/internal/tokens/tokens_test.go @@ -131,6 +131,26 @@ func TestPersistWriteAndRenameErrors(t *testing.T) { s2.Set("k", "v") // WriteFile ok, Rename onto a directory fails } +func TestConcurrentAccess(t *testing.T) { + // Smoke test for the Store's locking: run with -race to catch data races. + s := &Store{m: map[string]string{}} + done := make(chan struct{}) + for i := 0; i < 4; i++ { + go func(i int) { + defer func() { done <- struct{}{} }() + id := string(rune('a' + i)) + for j := 0; j < 50; j++ { + s.Set(id, "tok") + _ = s.Get(id) + s.Delete(id) + } + }(i) + } + for i := 0; i < 4; i++ { + <-done + } +} + func TestOpenWithCorruptFile(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", dir) diff --git a/internal/tui/last_gaps_test.go b/internal/tui/last_gaps_test.go new file mode 100644 index 0000000..d1851e0 --- /dev/null +++ b/internal/tui/last_gaps_test.go @@ -0,0 +1,356 @@ +package tui + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + ws "golang.org/x/net/websocket" + + "github.com/BackendStack21/bodek/internal/client" +) + +// TestShortenHome covers the $HOME replacement arms: exact home, nested path, +// foreign path, blank input, and the UserHomeDir error path ($HOME unset). +func TestShortenHome(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + cases := []struct{ in, want string }{ + {"", ""}, + {" ", ""}, + {home, "~"}, + {home + "/sub/dir", "~/sub/dir"}, + {"/elsewhere", "/elsewhere"}, + } + for _, c := range cases { + if got := shortenHome(c.in); got != c.want { + t.Errorf("shortenHome(%q) = %q, want %q", c.in, got, c.want) + } + } + + // Without $HOME, os.UserHomeDir errors and the path is returned verbatim. + t.Setenv("HOME", "") + if got := shortenHome("/abs/path"); got != "/abs/path" { + t.Errorf("shortenHome without $HOME = %q", got) + } +} + +// TestWelcomeWithCWD exercises the working-directory line of the splash. +func TestWelcomeWithCWD(t *testing.T) { + out := plain(welcome(newTheme(), 80, "/nonexistent/workdir")) + if !strings.Contains(out, "/nonexistent/workdir") { + t.Error("welcome splash missing the cwd line") + } +} + +func TestResourceGlyph(t *testing.T) { + cases := map[string]string{ + "command": "/", + "session": "⟳", + "skill": "✦", + "file": "≡", + "other": "≡", + } + for typ, want := range cases { + if got := resourceGlyph(typ); got != want { + t.Errorf("resourceGlyph(%q) = %q, want %q", typ, got, want) + } + } +} + +// TestRunStatsCommand drives the /stats registry closure through runCommand. +func TestRunStatsCommand(t *testing.T) { + m := wired(t) + if cmd := m.runCommand("stats", ""); cmd != nil { + t.Error("/stats should be a local command with no tea.Cmd") + } + if len(m.msgs) == 0 { + t.Error("/stats did not append the dashboard card") + } +} + +// TestShowStatsBranches hits the /stats card's conditional arms: the >100% +// context clamp, the slowest-turn latency suffix, thinking on, the narrow-width +// model budget floor, and the session-id title suffix. +func TestShowStatsBranches(t *testing.T) { + m := newTestModel() + m.resize(30, 30) // innerW = 24 → model budget falls below the 8-col floor + m.thinkOn = true + m.sessionID = "sess-123" + m.maxContext = 100 + m.sessCtxTok = 250 // 250% → clamped to 100% + m.sessionStart = time.Now().Add(-time.Minute) + m.turnStats = []turnStats{ + {latency: 1.0, thought: true}, + {latency: 6.0}, // peak 6.0 > mean 3.5 + 0.05 → "slowest" suffix + } + m.showStats() + if len(m.msgs) != 1 { + t.Fatalf("showStats appended %d messages", len(m.msgs)) + } + out := plain(m.msgs[0].rendered) + for _, want := range []string{"sess-123", "slowest 6.0s", "think on", "100%"} { + if !strings.Contains(out, want) { + t.Errorf("stats card missing %q", want) + } + } +} + +// TestEscWhenIdle covers the esc key with no run in flight (a no-op). +func TestEscWhenIdle(t *testing.T) { + m := wired(t) + m.Update(key("esc")) + if m.quitting { + t.Error("idle esc should not quit") + } +} + +// TestCtrlPanelKeys covers the ^R (sessions) and ^O (models) keybindings. +func TestCtrlPanelKeys(t *testing.T) { + m := wired(t) + + _, cmd := m.Update(key("ctrl+r")) + m.Update(exec(cmd)) + if m.panel != panelSessions { + t.Fatalf("ctrl+r did not open sessions panel: %d", m.panel) + } + m.Update(key("esc")) + + _, cmd = m.Update(key("ctrl+o")) + m.Update(exec(cmd)) + if m.panel != panelModels { + t.Fatalf("ctrl+o did not open models panel: %d", m.panel) + } + m.Update(key("esc")) +} + +// TestErrorEventWithoutActiveMessage covers the error-event fallback: with no +// streaming message to hold the error it becomes a notice. +func TestErrorEventWithoutActiveMessage(t *testing.T) { + m := wired(t) + m.handleEvent(client.Event{Type: "error", Message: "boom"}) + if len(m.notices) == 0 || !strings.Contains(m.notices[len(m.notices)-1], "boom") { + t.Errorf("error notice missing: %v", m.notices) + } +} + +// TestDisconnectWithLogPath covers the "server log" hint appended on +// disconnect when the spawned server captured stderr to a file. +func TestDisconnectWithLogPath(t *testing.T) { + m := wired(t) + m.opts.LogPath = "/tmp/odek-serve.log" + m.handleEvent(client.Event{Type: client.EventDisconnected}) + found := false + for _, n := range m.notices { + if strings.Contains(n, "server log") && strings.Contains(n, "/tmp/odek-serve.log") { + found = true + } + } + if !found { + t.Errorf("server-log notice missing: %v", m.notices) + } +} + +// TestSubmitThinkingEnabled covers the thinking=enabled prompt option. +func TestSubmitThinkingEnabled(t *testing.T) { + m := wired(t) + m.thinkOn = true + m.ta.SetValue("hi") + exec(m.submit()) + if !m.busy { + t.Fatal("model not busy after submit") + } + deadline := time.After(3 * time.Second) + for m.busy { + select { + case ev := <-m.cl.Events: + m.handleEvent(ev) + case <-deadline: + t.Fatal("did not receive done") + } + } +} + +// TestStepGlyphsDedupAndCap covers duplicate-glyph skipping and the 4-glyph +// cap in one pass. +func TestStepGlyphsDedupAndCap(t *testing.T) { + steps := []step{ + {name: "shell"}, // ❯ + {name: "bash_tool"}, // ❯ again — deduped + {name: "read_file"}, // ◰ + {name: "list_dir"}, // ▤ + {name: "grep"}, // ⌕ — fourth distinct glyph + {name: "write_file"}, // ✎ — beyond the cap + } + got := stepGlyphs(steps) + if len(got) != 4 { + t.Fatalf("stepGlyphs = %v (%d), want 4 glyphs", got, len(got)) + } + if got[0] != "❯" || got[1] != "◰" || got[2] != "▤" || got[3] != "⌕" { + t.Errorf("stepGlyphs order/dedup wrong: %v", got) + } +} + +// TestFetchModelsCmd executes the startup model-list fetch against the stand-in. +func TestFetchModelsCmd(t *testing.T) { + m := wired(t) + msg, ok := exec(m.fetchModels()).(modelsMsg) + if !ok { + t.Fatal("fetchModels did not return a modelsMsg") + } + if msg.err != nil || len(msg.items) != 1 { + t.Errorf("fetchModels = %d items, err %v", len(msg.items), msg.err) + } +} + +// TestRelayoutNotReady covers the early return before the first resize. +func TestRelayoutNotReady(t *testing.T) { + m := &Model{} + m.relayout() // must not panic or size anything + if m.ready { + t.Error("relayout marked an unready model ready") + } +} + +// TestSyncACSameCommandQuery covers the no-op when the command prefix is +// unchanged since the popup was opened. +func TestSyncACSameCommandQuery(t *testing.T) { + m := wired(t) + m.ta.SetValue("/st") + if cmd := m.syncAC(); cmd != nil { + t.Error("opening the command popup should not spawn a cmd") + } + if !m.ac.open || m.ac.mode != acCmd { + t.Fatal("command popup did not open") + } + if cmd := m.syncAC(); cmd != nil { + t.Error("unchanged command query should be a no-op") + } +} + +// TestSyncACTrimsToSixFiles covers the >6 file trim in the @-search result, +// against a stand-in that over-fetches nine files plus a non-file resource. +func TestSyncACTrimsToSixFiles(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + mux := http.NewServeMux() + mux.Handle("/ws", ws.Server{Handler: ws.Handler(func(c *ws.Conn) { + for { + var d []byte + if err := ws.Message.Receive(c, &d); err != nil { + return + } + } + })}) + mux.HandleFunc("/api/resources", func(w http.ResponseWriter, r *http.Request) { + rs := []client.Resource{{ID: "@sess:s1", Type: "session", Label: "s1"}} + for i := 0; i < 9; i++ { + rs = append(rs, client.Resource{ + ID: fmt.Sprintf("@f%d.go", i), Type: "file", Label: fmt.Sprintf("f%d.go", i), + }) + } + json.NewEncoder(w).Encode(rs) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + cl, err := client.Dial("ws"+strings.TrimPrefix(srv.URL, "http")+"/ws", srv.URL, srv.URL, "") + if err != nil { + t.Fatalf("Dial: %v", err) + } + t.Cleanup(func() { cl.Close() }) + + m := New(cl, Options{Model: "m"}) + m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + m.ta.SetValue("see @f") + msg, ok := exec(m.syncAC()).(acResultMsg) + if !ok { + t.Fatal("syncAC did not return an acResultMsg") + } + if len(msg.items) != 6 { + t.Errorf("@-search kept %d items, want 6 (files only, capped)", len(msg.items)) + } +} + +// TestElapsedMinutes covers the ≥1-minute branch of the live run badge. +func TestElapsedMinutes(t *testing.T) { + m := newTestModel() + m.runStart = time.Now().Add(-65 * time.Second) + if got := m.elapsed(); got != "1m05s" { + t.Errorf("elapsed = %q, want 1m05s", got) + } +} + +// TestCapThinkingRuneBoundary covers the rune-count early return: a builder +// over the byte cap but under the rune cap is left untouched. +func TestCapThinkingRuneBoundary(t *testing.T) { + var b strings.Builder + b.WriteString("ééé") // 6 bytes > 4, but 3 runes ≤ 4 + capThinking(&b, 4) + if b.String() != "ééé" { + t.Errorf("capThinking trimmed a rune-fitting builder to %q", b.String()) + } +} + +// TestCtxGaugeClamps covers the negative and >100% ratio clamps in the header +// gauge. +func TestCtxGaugeClamps(t *testing.T) { + m := newTestModel() + m.maxContext = 100 + + m.sessCtxTok = -5 // corrupt/negative accounting → clamp to 0% + if out := plain(m.ctxGauge(true)); !strings.Contains(out, "0%") { + t.Errorf("negative ratio gauge = %q, want 0%%", out) + } + + m.sessCtxTok = 250 // overrun → clamp to 100% + if out := plain(m.ctxGauge(false)); !strings.Contains(out, "100%") { + t.Errorf("overrun ratio gauge = %q, want 100%%", out) + } +} + +// TestGaugeColorBands covers all three pressure tints. +func TestGaugeColorBands(t *testing.T) { + m := newTestModel() + // Not a TTY in tests, so compare the style colors rather than the output. + hot := m.gaugeColor(0.95).GetForeground() + warm := m.gaugeColor(0.80).GetForeground() + ok := m.gaugeColor(0.10).GetForeground() + if hot == warm || warm == ok || hot == ok { + t.Error("gauge bands should use distinct tints") + } +} + +// TestAcPopupCmdModeAndNarrow covers the command-mode label/empty-text arms +// and the inner-width floor on very narrow terminals. +func TestAcPopupCmdModeAndNarrow(t *testing.T) { + m := newTestModel() + m.width = 17 // innerW = 11 → clamped to 12 + m.ac = autocomplete{open: true, mode: acCmd, items: []client.Resource{ + {ID: "/help", Type: "command", Label: "/help", Detail: "show available commands"}, + }} + if out := plain(m.acPopup()); !strings.Contains(out, "commands") { + t.Error("command-mode popup missing its label") + } + + m.ac.items = nil + if out := plain(m.acPopup()); !strings.Contains(out, "no matching") { + t.Error("empty command popup missing its placeholder") + } +} + +// TestFooterNarrowGap covers the gap-floor when the right-hand segments are +// wider than the terminal. +func TestFooterNarrowGap(t *testing.T) { + m := newTestModel() + m.width = 5 + m.lastLatency = 2.5 + m.turnStats = []turnStats{{outTok: 12}} + _ = m.footer() // must not panic on a negative gap +}