Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c8752ce
fix(server): keep odek serve alive and stoppable around stderr
jkyberneees Oct 12, 2026
de680d5
fix(watchdog): identify the parent by start time, not pid alone
jkyberneees Oct 12, 2026
0cac82f
fix(bodek): let a second signal abort a slow shutdown
jkyberneees Oct 12, 2026
86959f4
fix(bodek): never overwrite a settings file broken mid-run
jkyberneees Oct 12, 2026
b3030d5
fix(update): rank a release above its own pseudo-version builds
jkyberneees Oct 12, 2026
209781d
fix(update): bound the release metadata decode
jkyberneees Oct 12, 2026
4723a05
fix(client): bound every REST JSON decode
jkyberneees Oct 12, 2026
d41a48a
fix(client): never follow redirects on authenticated REST calls
jkyberneees Oct 12, 2026
6a62c73
fix(client): bound websocket frame writes with a deadline
jkyberneees Oct 12, 2026
e775ba9
fix(tokens): keep unpersisted tokens across the disk merge
jkyberneees Oct 12, 2026
5f6d494
fix(settings): create ~/.bodek with 0700
jkyberneees Oct 12, 2026
60d82df
fix(workspace): persist under the store mutex
jkyberneees Oct 12, 2026
947984d
fix(tui): sanitize wire text at the remaining terminal sinks
jkyberneees Oct 12, 2026
fe4b63f
fix(tui): attach regular files only, with a capped read
jkyberneees Oct 12, 2026
78f6de9
fix(tui): keep armed actions and selections on their target
jkyberneees Oct 12, 2026
f888df0
fix(tui): refuse session resume while a turn runs
jkyberneees Oct 12, 2026
9e7fdea
fix(tui): run one approval expiry sweep at a time
jkyberneees Oct 12, 2026
5120f2f
fix(tui): drop jobs and plan replies for a session already left
jkyberneees Oct 12, 2026
e0db69e
fix(tui): never type key sentinels or chords into hidden inputs
jkyberneees Oct 12, 2026
f4ba41e
fix(tui): clamp an expired poll timeout to zero
jkyberneees Oct 12, 2026
610275a
fix(tui): show tool arguments without HTML escaping
jkyberneees Oct 12, 2026
9d9c142
fix(server): keep the orphan watchdog armed until odek exits
jkyberneees Oct 12, 2026
c6f6a4f
fix(server): send the leader fallback through Process.Signal
jkyberneees Oct 12, 2026
6091813
fix(tui): hold sends until an in-flight session resume lands
jkyberneees Oct 12, 2026
d7bf6b5
fix(tui): keep memory confirms on the exact row identity
jkyberneees Oct 12, 2026
e3e57bb
fix(tui): sanitize plan step status in the detail view
jkyberneees Oct 12, 2026
a684756
fix(tui): never silently re-attach a path a symlink was planted on
jkyberneees Oct 12, 2026
45d5a5c
fix(tui): paint /attach and /unattach outcome notes immediately
jkyberneees Oct 12, 2026
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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,8 @@ modifier routes to the composer), and `Alt+A`/`Alt+D`/`Alt+T`
minted token, and never returns that stub as a session. Replayed user
turns show `principal_prompt` (what was typed), not `content`, which
carries attachments and @-resources inlined in untrusted wrappers.
`handleSessionDetail` refuses a resume while `busy` — the running turn's
frames would stream into a replayed message at `curIdx`.
- The TUI reconnects with backoff and resumes the session after a socket
drop (`reconnect.go`) — don't break that by assuming a single
connection per run.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,8 @@ own front-end settings are separate; see [Configuration](#configuration).
- **Session browser** (`^R`) — resume, replay, delete, pin (`p`), rename
(`r`), export a transcript (`e` markdown, `E` JSON), and search server-side
(`/`); `n` loads the next page. Resuming sends a `session_switch` so the
server-side memory buffer is restored before you type.
server-side memory buffer is restored before you type. Resume is refused
while a turn is running — finish it or cancel with `^X` first.
- **Last-session resume (opt-in)** — by default a launch starts fresh.
Enable continuation per launch with `--resume`, or persistently with
`{"resume": true}` in `~/.bodek/config.json`. Resuming replays the
Expand Down
7 changes: 7 additions & 0 deletions cmd/bodek/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ type ringWriter struct {
partial []byte
}

// maxPartialBytes caps the unterminated trailing line a ringWriter keeps.
const maxPartialBytes = 64 << 10

func newRingWriter(maxLine int) *ringWriter {
return &ringWriter{maxLine: maxLine}
}
Expand All @@ -60,6 +63,10 @@ func (w *ringWriter) Write(p []byte) (int, error) {
w.lines = w.lines[len(w.lines)-w.maxLine:]
}
}
// Newline-free output (\r progress bars) must not grow without bound.
if n := len(w.partial); n > maxPartialBytes {
w.partial = append([]byte(nil), w.partial[n-maxPartialBytes:]...)
}
return len(p), nil
}

Expand Down
11 changes: 11 additions & 0 deletions cmd/bodek/log_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,14 @@ func TestOpenServerLogTruncates(t *testing.T) {
t.Fatal("prior run's log was not truncated")
}
}

func TestRingWriterCapsPartialLine(t *testing.T) {
w := newRingWriter(3)
chunk := []byte(strings.Repeat("x", 4096))
for range 64 {
_, _ = w.Write(chunk)
}
if len(w.partial) > maxPartialBytes {
t.Fatalf("partial = %d bytes, want <= %d", len(w.partial), maxPartialBytes)
}
}
9 changes: 8 additions & 1 deletion cmd/bodek/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,11 @@ func (c *config) save() error {
}
cur, err := settings.Load()
if err != nil {
cur = c.persisted // file vanished or changed shape: fall back to our baseline
// A missing file loads clean; an error means the file became
// unreadable or unparsable mid-run (a hand-edit typo). Replacing it
// would clobber those edits — the same rule persistDisabled enforces
// at startup.
return fmt.Errorf("settings file could not be read; change not saved: %w", err)
}
rb, rc, rd := reflect.ValueOf(c.persisted), reflect.ValueOf(c.persist), reflect.ValueOf(&cur).Elem()
for i := range rc.NumField() {
Expand Down Expand Up @@ -370,6 +374,9 @@ func setupSignalHandler(p *tea.Program) {
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
go func() {
<-ch
// Restore default handling so a second Ctrl+C / SIGTERM can abort a
// slow shutdown instead of being swallowed by this channel.
signal.Stop(ch)
p.Quit()
}()
}
22 changes: 22 additions & 0 deletions cmd/bodek/settings_persist_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,25 @@ func TestParseConfigBrokenFileRefusesPersistence(t *testing.T) {
t.Fatalf("expected saved settings file: %v", err)
}
}

// A file broken mid-run (a hand-edit typo after a clean startup) must not
// be replaced by the next preference change either.
func TestSaveRefusesFileBrokenMidRun(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
t.Setenv("BODEK_CONFIG", path)
cfg, err := parseConfig(nil, &bytes.Buffer{})
if err != nil || cfg.persistDisabled {
t.Fatalf("parseConfig: %v, disabled=%v", err, cfg.persistDisabled)
}
broken := `{"theme": "dracula",`
if err := os.WriteFile(path, []byte(broken), 0o600); err != nil {
t.Fatal(err)
}
cfg.persist.Theme = "ember-light"
if err := cfg.save(); err == nil {
t.Fatal("save over a broken file must report an error")
}
if data, _ := os.ReadFile(path); string(data) != broken {
t.Fatalf("broken settings file was overwritten: %q", data)
}
}
9 changes: 7 additions & 2 deletions cmd/bodek/upgrade_swap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,17 @@ func TestUpgradeSlowLinkDownloads(t *testing.T) {
// TestNewerPseudoVersion guards commit-installed builds: a Go pseudo-version
// stamp (v0.1.3-0.20260901abcdef12-abc1234) must compare by its release
// prefix, not report "already up to date" against every future release.
// The stamp names the *next* patch (a commit after v0.1.2), so the v0.1.3
// release itself is newer.
func TestNewerPseudoVersion(t *testing.T) {
if !update.Newer("v9.9.9", "v0.1.3-0.20260901000000-abc1234") {
t.Fatal("v9.9.9 must be newer than a v0.1.3 pseudo-version stamp")
}
if update.Newer("v0.1.3", "v0.1.3-0.20260901000000-abc1234") {
t.Fatal("v0.1.3 must not upgrade over a v0.1.3 pseudo-version")
if !update.Newer("v0.1.3", "v0.1.3-0.20260901000000-abc1234") {
t.Fatal("v0.1.3 must be newer than the v0.1.3 pseudo-version that predates it")
}
if update.Newer("v0.1.2", "v0.1.3-0.20260901000000-abc1234") {
t.Fatal("v0.1.2 must not upgrade over a later commit")
}
// A genuine semver prerelease must not be misparsed as a pseudo-version.
if !update.Newer("v1.3.0", "v1.2.0-0.1") {
Expand Down
23 changes: 20 additions & 3 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
Expand Down Expand Up @@ -309,7 +310,7 @@ func Dial(wsURL, origin, baseURL, token string) (*Client, error) {
conn: conn,
baseURL: baseURL,
serveToken: token,
http: &http.Client{Timeout: 3 * time.Second},
http: &http.Client{Timeout: 3 * time.Second, CheckRedirect: noRedirect},
// Thinking models stream hundreds of delta frames per turn. The TUI
// batch-drains this channel, but a deep buffer keeps readLoop from
// blocking (which trips odek's 30s write watchdog and drops the socket).
Expand All @@ -318,7 +319,7 @@ func Dial(wsURL, origin, baseURL, token string) (*Client, error) {
// exports) legitimately take longer than interactive REST calls; a
// dedicated 30s client serves them so the 3s interactive budget does
// not cut their body reads.
slowHTTP: &http.Client{Timeout: 30 * time.Second},
slowHTTP: &http.Client{Timeout: 30 * time.Second, CheckRedirect: noRedirect},
done: make(chan struct{}),
}
go c.readLoop()
Expand Down Expand Up @@ -399,7 +400,7 @@ func (c *Client) Resources(query string, limit int) ([]Resource, error) {
return nil, fmt.Errorf("resources: status %s", resp.Status)
}
var out []Resource
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
if err := json.NewDecoder(io.LimitReader(resp.Body, maxJSONBytes)).Decode(&out); err != nil {
return nil, err
}
return out, nil
Expand Down Expand Up @@ -618,13 +619,29 @@ func (c *Client) SendSkillPromptResponse(action, skillName string) error {
}{Type: "skill_prompt_response", Action: action, SkillName: skillName})
}

// writeTimeout bounds one frame write. A peer that stops reading would
// otherwise park send forever while holding wmu, wedging every later
// approval and cancel behind it. Generous enough for a 10 MB attachment
// prompt on a slow link.
var writeTimeout = 60 * time.Second

// send marshals v and writes it as one frame, serialized against other writers.
func (c *Client) send(v any) error {
c.wmu.Lock()
defer c.wmu.Unlock()
_ = c.conn.SetWriteDeadline(time.Now().Add(writeTimeout))
return ws.JSON.Send(c.conn, v)
}

// noRedirect keeps REST calls on the origin they were addressed to. Go
// strips Authorization on cross-host redirects but copies custom headers,
// so following one would hand X-Odek-Ws-Token / X-Session-Token (and a
// replayed POST body on 307/308) to another host. odek's API never
// redirects; the 3xx reaches the caller as a status error.
func noRedirect(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}

// Close shuts the connection and releases a readLoop parked on Events.
func (c *Client) Close() error {
c.closeOnce.Do(func() {
Expand Down
9 changes: 5 additions & 4 deletions internal/client/jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
Expand Down Expand Up @@ -63,7 +64,7 @@ func (c *Client) Jobs(sessionID, token string) ([]Job, error) {
return nil, fmt.Errorf("jobs: status %s", resp.Status)
}
var env jobsEnvelope
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
if err := json.NewDecoder(io.LimitReader(resp.Body, maxJSONBytes)).Decode(&env); err != nil {
return nil, err
}
return env.Jobs, nil
Expand All @@ -86,7 +87,7 @@ func (c *Client) JobOutput(sessionID, token, jobID string, since int) (JobOutput
case resp.StatusCode != http.StatusOK:
return out, fmt.Errorf("job output: status %s", resp.Status)
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
if err := json.NewDecoder(io.LimitReader(resp.Body, maxJSONBytes)).Decode(&out); err != nil {
return out, err
}
return out, nil
Expand All @@ -97,7 +98,7 @@ func (c *Client) JobOutput(sessionID, token, jobID string, since int) (JobOutput
// bgproc.Manager.Stop), which the shared 3s client would abort mid-kill.
func (c *Client) stopHTTP() *http.Client {
c.stopOnce.Do(func() {
c.stopClient = &http.Client{Timeout: 12 * time.Second}
c.stopClient = &http.Client{Timeout: 12 * time.Second, CheckRedirect: noRedirect}
})
return c.stopClient
}
Expand Down Expand Up @@ -127,7 +128,7 @@ func (c *Client) StopJob(sessionID, token, jobID string) (string, error) {
var body struct {
Status string `json:"status"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
if err := json.NewDecoder(io.LimitReader(resp.Body, maxJSONBytes)).Decode(&body); err != nil {
return "", err
}
return body.Status, nil
Expand Down
3 changes: 2 additions & 1 deletion internal/client/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
)
Expand Down Expand Up @@ -65,7 +66,7 @@ func (c *Client) SessionPlan(sessionID, sessionToken string) (PlanSnapshot, erro
return PlanSnapshot{}, fmt.Errorf("session plan: status %s", resp.Status)
}
var out PlanSnapshot
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
if err := json.NewDecoder(io.LimitReader(resp.Body, maxJSONBytes)).Decode(&out); err != nil {
return PlanSnapshot{}, fmt.Errorf("session plan: %w", err)
}
return out, nil
Expand Down
52 changes: 52 additions & 0 deletions internal/client/redirect_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package client

import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"

ws "golang.org/x/net/websocket"
)

// REST calls must never follow a redirect: Go copies the custom token
// headers onto a cross-host hop (and replays POST bodies on 307/308).
func TestStopJobDoesNotFollowRedirect(t *testing.T) {
var leaked atomic.Bool
evil := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Odek-Ws-Token") != "" || r.Header.Get("X-Session-Token") != "" {
leaked.Store(true)
}
_, _ = w.Write([]byte(`{"status":"stopped"}`))
}))
defer evil.Close()
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, evil.URL+r.URL.Path, http.StatusTemporaryRedirect)
}))
defer origin.Close()

c := &Client{baseURL: origin.URL, serveToken: "instance-secret"}
if _, err := c.StopJob("s1", "session-secret", "j1"); err == nil {
t.Fatal("StopJob followed a redirect; want a status error")
}
if leaked.Load() {
t.Fatal("auth headers reached the redirect target")
}
}

// The REST clients Dial builds (interactive and slow) refuse redirects too.
func TestDialClientsDoNotFollowRedirects(t *testing.T) {
base := validateServer(t, func(c *ws.Conn) { time.Sleep(2 * time.Second) }, nil)
c, err := Dial("ws"+strings.TrimPrefix(base, "http")+"/ws", base, base, "tok")
if err != nil {
t.Fatal(err)
}
defer func() { _ = c.Close() }()
for name, hc := range map[string]*http.Client{"http": c.http, "slowHTTP": c.slowHTTP} {
if hc.CheckRedirect == nil || hc.CheckRedirect(nil, nil) != http.ErrUseLastResponse {
t.Errorf("%s follows redirects", name)
}
}
}
2 changes: 1 addition & 1 deletion internal/client/rest.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ func (c *Client) sessionDetailOnce(id, token string) (s Session, eff string, boo
Session
Bootstrapped bool `json:"bootstrapped"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
if err := json.NewDecoder(io.LimitReader(resp.Body, maxJSONBytes)).Decode(&body); err != nil {
return s, "", false, err
}
eff = resp.Header.Get("X-Session-Token")
Expand Down
3 changes: 2 additions & 1 deletion internal/client/runs.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package client
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
Expand Down Expand Up @@ -108,7 +109,7 @@ func (c *Client) StartRun(content string, opts RunOpts) (Run, error) {
SessionID string `json:"session_id"`
Status string `json:"status"`
}
if err := json.NewDecoder(resp.Body).Decode(&started); err != nil {
if err := json.NewDecoder(io.LimitReader(resp.Body, maxJSONBytes)).Decode(&started); err != nil {
return Run{}, err
}
return Run{ID: started.RunID, SessionID: started.SessionID, Status: started.Status}, nil
Expand Down
10 changes: 6 additions & 4 deletions internal/server/pgroup_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ func setPgroup(cmd *exec.Cmd) {

// signalServer sends sig to the spawned server's whole process group (the
// server's own subprocesses follow it down), falling back to the leader
// when the group is gone. Safe because Stop holds a live Process handle
// for this pid — no recycled-PID window.
// when the group is gone. The leader fallback goes through Process.Signal,
// which reports ErrProcessDone as soon as wait4 reaped the child — the raw
// pid may already belong to another process, and the reaper's flag lags
// that moment by up to WaitDelay while stderr drains.
func (c *Conn) signalServer(sig syscall.Signal) {
if c.proc == nil || c.proc.Process == nil {
return
}
if err := syscall.Kill(-c.proc.Process.Pid, sig); err != nil {
_ = syscall.Kill(c.proc.Process.Pid, sig)
if err := syscall.Kill(-c.proc.Process.Pid, sig); err != nil && !c.reaped.Load() {
_ = c.proc.Process.Signal(sig)
}
}
39 changes: 39 additions & 0 deletions internal/server/scan_bounds_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package server

import (
"errors"
"strings"
"testing"
)

type failWriter struct{}

func (failWriter) Write([]byte) (int, error) { return 0, errors.New("disk full") }

// A failing log sink must not surface to os/exec, whose copy goroutine
// would close the stderr pipe and kill odek with SIGPIPE.
func TestTokenScanWriterSwallowsSinkErrors(t *testing.T) {
s := &tokenScanWriter{w: failWriter{}}
n, err := s.Write([]byte("WS token: abc\n"))
if err != nil || n != 15 {
t.Fatalf("Write = %d, %v; want 15, nil", n, err)
}
if s.Token() != "abc" {
t.Fatalf("token = %q, want abc", s.Token())
}
}

// Newline-free output (\r progress bars) must not grow the buffer unbounded,
// before or after the token is found.
func TestTokenScanWriterCapsPartialLine(t *testing.T) {
chunk := []byte(strings.Repeat("x", 4096))
for _, tok := range []string{"", "found"} {
s := &tokenScanWriter{w: failWriter{}, tok: tok}
for range 64 {
_, _ = s.Write(chunk)
}
if len(s.buf) > maxPartialLine {
t.Fatalf("tok=%q: partial buffer %d bytes, want <= %d", tok, len(s.buf), maxPartialLine)
}
}
}
Loading
Loading