Skip to content

Commit bed110e

Browse files
authored
fix: close 9 defects from adversarial bug-hunt sweep (#125)
- client: close the socket when readLoop exits, so reconnect swaps stop leaking fds and server slots; TUI closes the replaced old client - server: grace-poll the spawned stderr token before the legacy probe (HTTP can answer before the token line flushes and 403-fail a server bodek itself spawned) and fail fast if the child exits mid-wait - tokens: serialize persistence under the store mutex (interleaved Set/Delete could resurrect deleted tokens on disk) and quarantine a corrupt store as sessions.json.corrupt instead of silently wiping it - settings: staged tmp+rename save so a crash cannot truncate config.json - workspace: report failed persists on stderr instead of swallowing them - client: 30s client for bulk reads (session detail, export); export errors instead of silently truncating past the 64 MiB bound - cmd: SIGINT/SIGTERM quits the Bubble Tea program instead of a side- goroutine os.Exit that skipped terminal restore and deferred teardown - tui: regression tests for untrusted-wrapper folding
1 parent b3d298a commit bed110e

11 files changed

Lines changed: 322 additions & 47 deletions

File tree

‎cmd/bodek/main.go‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -228,9 +228,6 @@ func run() error {
228228
cwd = "."
229229
}
230230

231-
// Gracefully shutdown on SIGINT/SIGTERM so the server gets a clean exit.
232-
setupSignalHandler(srv, cl)
233-
234231
model := tui.New(cl, tui.Options{
235232
Sandbox: cfg.sandbox,
236233
CWD: cwd,
@@ -268,20 +265,25 @@ func run() error {
268265
tui.RestoreEnhancedKeys()
269266
p := tea.NewProgram(model, buildProgramOptions(cfg.plain)...)
270267
defer tui.RestoreEnhancedKeys()
268+
269+
// SIGINT/SIGTERM must not hard-exit from a side goroutine: racing Bubble
270+
// Tea's own signal handling leaves the alt screen up and raw mode on
271+
// (RestoreEnhancedKeys and the deferred restores never run), corrupting
272+
// the terminal. Quit the program instead — Run returns cleanly, so the
273+
// deferred srv.Stop() and cl.Close() still execute with terminal state
274+
// intact.
275+
setupSignalHandler(p)
271276
if _, err := p.Run(); err != nil {
272277
return fmt.Errorf("TUI exited: %w", err)
273278
}
274279
return nil
275280
}
276281

277-
func setupSignalHandler(srv *server.Conn, cl *client.Client) {
282+
func setupSignalHandler(p *tea.Program) {
278283
ch := make(chan os.Signal, 1)
279284
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
280285
go func() {
281286
<-ch
282-
_ = cl.Close()
283-
srv.Stop()
284-
tui.RestoreEnhancedKeys()
285-
os.Exit(0)
287+
p.Quit()
286288
}()
287289
}

‎internal/client/client.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ type Client struct {
245245

246246
stopOnce sync.Once // lazy: only jobs-stop pays the longer timeout budget
247247
stopClient *http.Client // 12s — odek's stop endpoint blocks stopGrace+4s
248+
slowHTTP *http.Client // 30s — bulk payload reads (session detail, export)
248249
}
249250

250251
// Dial connects to an odek serve WebSocket. wsURL is the ws:// endpoint,
@@ -273,6 +274,11 @@ func Dial(wsURL, origin, baseURL, token string) (*Client, error) {
273274
// batch-drains this channel, but a deep buffer keeps readLoop from
274275
// blocking (which trips odek's 30s write watchdog and drops the socket).
275276
Events: make(chan Event, eventBuffer),
277+
// Large transcript payloads (SessionDetail of a long session, full
278+
// exports) legitimately take longer than interactive REST calls; a
279+
// dedicated 30s client serves them so the 3s interactive budget does
280+
// not cut their body reads.
281+
slowHTTP: &http.Client{Timeout: 30 * time.Second},
276282
}
277283
go c.readLoop()
278284
return c, nil
@@ -309,6 +315,7 @@ var readIdleTimeout = 45 * time.Second
309315

310316
func (c *Client) readLoop() {
311317
defer close(c.Events)
318+
defer c.conn.Close() // release the fd even when the sender never closes (reconnect swap)
312319
var pending *Event
313320
n := 0
314321
flush := func() {

‎internal/client/export_red_test.go‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package client
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"strings"
7+
"testing"
8+
)
9+
10+
// RED: ExportSession must not silently truncate an export exceeding the
11+
// in-memory bound — it must error, not return a clipped file.
12+
func TestExportSessionRejectsOversized(t *testing.T) {
13+
big := strings.Repeat("x", maxExportBytes+1)
14+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
15+
_, _ = w.Write([]byte(big))
16+
}))
17+
defer srv.Close()
18+
19+
c := &Client{baseURL: srv.URL, http: srv.Client()}
20+
if _, err := c.ExportSession("s1", "", "md"); err == nil {
21+
t.Fatal("oversized export silently truncated; want error")
22+
}
23+
}

‎internal/client/rest.go‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,15 +116,24 @@ func (c *Client) UpdateSession(id, token string, name *string, pinned *bool) err
116116
func (c *Client) ExportSession(id, token, format string) ([]byte, error) {
117117
u := fmt.Sprintf("%s/api/sessions/%s/export?format=%s",
118118
c.baseURL, url.PathEscape(id), url.QueryEscape(format))
119-
resp, err := c.do(http.MethodGet, u, token)
119+
resp, err := c.doWith(c.slowHTTP, http.MethodGet, u, token)
120120
if err != nil {
121121
return nil, err
122122
}
123123
defer func() { _ = resp.Body.Close() }()
124124
if resp.StatusCode != http.StatusOK {
125125
return nil, fmt.Errorf("export: status %s", resp.Status)
126126
}
127-
return io.ReadAll(io.LimitReader(resp.Body, maxExportBytes))
127+
data, err := io.ReadAll(io.LimitReader(resp.Body, maxExportBytes+1))
128+
if err != nil {
129+
return nil, err
130+
}
131+
if int64(len(data)) > maxExportBytes {
132+
// Read one byte past the bound so an oversized export fails loudly
133+
// instead of returning a silently clipped transcript.
134+
return nil, fmt.Errorf("export too large (exceeds %d bytes)", maxExportBytes)
135+
}
136+
return data, nil
128137
}
129138

130139
// maxExportBytes bounds an exported transcript read into memory.
@@ -157,7 +166,7 @@ func (c *Client) Health() (Health, error) {
157166
// back to the token passed in.
158167
func (c *Client) SessionDetail(id, token string) (Session, string, error) {
159168
var s Session
160-
resp, err := c.do(http.MethodGet, c.baseURL+"/api/sessions/"+url.PathEscape(id), token)
169+
resp, err := c.doWith(c.slowHTTP, http.MethodGet, c.baseURL+"/api/sessions/"+url.PathEscape(id), token)
161170
if err != nil {
162171
return s, "", err
163172
}
@@ -327,6 +336,13 @@ func (c *Client) Subagents(runKey string) ([]SubagentEntry, error) {
327336
}
328337

329338
func (c *Client) do(method, u, sessionToken string) (*http.Response, error) {
339+
return c.doWith(c.http, method, u, sessionToken)
340+
}
341+
342+
func (c *Client) doWith(h *http.Client, method, u, sessionToken string) (*http.Response, error) {
343+
if h == nil {
344+
h = c.http // zero-value Clients fall back to the interactive budget
345+
}
330346
req, err := http.NewRequest(method, u, nil)
331347
if err != nil {
332348
return nil, err
@@ -337,7 +353,7 @@ func (c *Client) do(method, u, sessionToken string) (*http.Response, error) {
337353
if sessionToken != "" {
338354
req.Header.Set("X-Session-Token", sessionToken)
339355
}
340-
return c.http.Do(req)
356+
return h.Do(req)
341357
}
342358

343359
// postJSON issues a JSON POST with the standard token headers attached.

‎internal/server/server.go‎

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ func Connect(opts Options) (*Conn, error) {
139139
}
140140
// A current odek prints its token to stderr at startup; an older one
141141
// prints nothing, so stop waiting as soon as the server answers.
142-
if err := waitSpawned(c.BaseURL, c.scan, readyTimeout); err != nil {
142+
if err := waitSpawned(c.BaseURL, c.scan, c.procAlive, readyTimeout); err != nil {
143143
c.Stop()
144144
return nil, fmt.Errorf("odek serve did not become ready: %w", err)
145145
}
@@ -383,20 +383,50 @@ func waitReady(baseURL string, timeout time.Duration) error {
383383
return fmt.Errorf("timed out after %s", timeout)
384384
}
385385

386-
// waitSpawned waits until a spawned server prints its token line or answers
387-
// HTTP, whichever comes first. Old odek versions print no token line, so
388-
// readiness alone also ends the wait (the legacy token path handles those).
389-
func waitSpawned(baseURL string, scan *tokenScanWriter, timeout time.Duration) error {
386+
// procAlive reports whether the spawned child is still running. Signal(0)
387+
// probes liveness without waiting: ProcessState stays nil until Wait is
388+
// called (Stop's job, after Connect returns), so it can never report death
389+
// here.
390+
func (c *Conn) procAlive() bool {
391+
return c.proc != nil && c.proc.Process != nil &&
392+
c.proc.Process.Signal(syscall.Signal(0)) == nil
393+
}
394+
395+
// waitSpawned waits until a spawned server answers HTTP or prints its token
396+
// line. Old odek versions print no token, so readiness alone eventually ends
397+
// the wait (the legacy token path handles those) — but only after a short
398+
// grace poll for the token: the HTTP listener can answer before the stderr
399+
// token line flushes, and falling to the legacy probe in that window makes
400+
// /api/models 403 hard-fail a server bodek itself just spawned. A child that
401+
// dies mid-wait fails immediately instead of burning the full timeout.
402+
func waitSpawned(baseURL string, scan *tokenScanWriter, alive func() bool, timeout time.Duration) error {
390403
deadline := time.Now().Add(timeout)
404+
readyAt := time.Time{}
391405
for time.Now().Before(deadline) {
392-
if scan.Token() != "" || probeReady(baseURL) {
406+
if scan.Token() != "" {
393407
return nil
394408
}
409+
if probeReady(baseURL) {
410+
if readyAt.IsZero() {
411+
readyAt = time.Now()
412+
} else if time.Since(readyAt) >= spawnedTokenGrace {
413+
return nil // genuinely a tokenless (old) server
414+
}
415+
} else if !readyAt.IsZero() {
416+
readyAt = time.Time{} // flapping: restart the grace clock
417+
}
418+
if alive != nil && !alive() {
419+
return fmt.Errorf("odek serve exited before becoming ready")
420+
}
395421
time.Sleep(150 * time.Millisecond)
396422
}
397423
return fmt.Errorf("timed out after %s", timeout)
398424
}
399425

426+
// spawnedTokenGrace is how long a ready-but-tokenless spawned server is
427+
// polled for the stderr token line before the legacy path takes over.
428+
const spawnedTokenGrace = 2 * time.Second
429+
400430
// probeReady reports whether the server root answers without a server error.
401431
func probeReady(baseURL string) bool {
402432
ctx, cancel := context.WithTimeout(context.Background(), time.Second)

‎internal/settings/settings.go‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,31 @@ func Save(s Settings) error {
7777
if err != nil {
7878
return err
7979
}
80-
return os.WriteFile(path, append(data, '\n'), 0o600)
80+
// Staged tmp + rename: a crash mid plain WriteFile left a truncated
81+
// config.json, which Load reports as malformed — every setting reset.
82+
tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".*.tmp")
83+
if err != nil {
84+
return err
85+
}
86+
tmpName := tmp.Name()
87+
if _, err := tmp.Write(append(data, '\n')); err != nil {
88+
_ = tmp.Close()
89+
_ = os.Remove(tmpName)
90+
return err
91+
}
92+
if err := tmp.Close(); err != nil {
93+
_ = os.Remove(tmpName)
94+
return err
95+
}
96+
if err := os.Chmod(tmpName, 0o600); err != nil {
97+
_ = os.Remove(tmpName)
98+
return err
99+
}
100+
if err := os.Rename(tmpName, path); err != nil {
101+
_ = os.Remove(tmpName) // don't leave the staged copy behind
102+
return err
103+
}
104+
return nil
81105
}
82106

83107
// Bool resolves an optional persisted boolean against its built-in default.

‎internal/tokens/tokens.go‎

Lines changed: 39 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -24,23 +24,38 @@ type Store struct {
2424
path string
2525
}
2626

27-
// Open loads the token store from ~/.bodek/sessions.json. It never fails: on
28-
// any error it returns an in-memory-only store.
27+
// Open loads the token store from ~/.bodek/sessions.json. It never fails:
28+
// any problem yields an in-memory-only store.
2929
func Open() *Store {
30-
s := &Store{m: map[string]string{}}
3130
home, err := os.UserHomeDir()
3231
if err != nil {
33-
return s
32+
return &Store{m: map[string]string{}}
3433
}
35-
s.path = filepath.Join(home, ".bodek", "sessions.json")
36-
if data, err := os.ReadFile(s.path); err == nil {
37-
_ = json.Unmarshal(data, &s.m)
38-
// JSON null unmarshals into a nil map; keep an empty map so Set
39-
// cannot panic on assignment. Corrupt JSON still leaves the
40-
// in-memory-empty store Open already constructed.
41-
if s.m == nil {
42-
s.m = map[string]string{}
34+
return openAt(filepath.Join(home, ".bodek", "sessions.json"))
35+
}
36+
37+
// openAt loads a store from an explicit path (test seam). A corrupt store is
38+
// quarantined as <path>.corrupt instead of being silently overwritten —
39+
// silently dropping every saved token on one bad write made resume loss
40+
// undiagnosable.
41+
func openAt(path string) *Store {
42+
s := &Store{m: map[string]string{}, path: path}
43+
data, err := os.ReadFile(path)
44+
if err != nil {
45+
return s // missing store: fresh start
46+
}
47+
if err := json.Unmarshal(data, &s.m); err != nil {
48+
if qErr := os.Rename(path, path+".corrupt"); qErr == nil {
49+
warnPersist(fmt.Errorf("corrupt store quarantined as %s.corrupt: %w", path, err))
50+
} else {
51+
warnPersist(fmt.Errorf("corrupt store kept in place: %w", err))
52+
s.path = "" // never overwrite bytes we could not parse
4353
}
54+
s.m = map[string]string{}
55+
return s
56+
}
57+
if s.m == nil { // JSON null unmarshals into a nil map
58+
s.m = map[string]string{}
4459
}
4560
return s
4661
}
@@ -61,20 +76,12 @@ func (s *Store) Set(id, token string) {
6176
return
6277
}
6378
s.mu.Lock()
79+
defer s.mu.Unlock()
6480
if s.m[id] == token {
65-
s.mu.Unlock()
6681
return // no change; skip the disk write
6782
}
6883
s.m[id] = token
69-
snapshot := make(map[string]string, len(s.m))
70-
for k, v := range s.m {
71-
snapshot[k] = v
72-
}
73-
path := s.path
74-
s.mu.Unlock()
75-
if err := persist(path, snapshot); err != nil {
76-
warnPersist(err)
77-
}
84+
s.persistLocked()
7885
}
7986

8087
// Delete removes a session's token and persists the store (best-effort).
@@ -83,18 +90,24 @@ func (s *Store) Delete(id string) {
8390
return
8491
}
8592
s.mu.Lock()
93+
defer s.mu.Unlock()
8694
if _, ok := s.m[id]; !ok {
87-
s.mu.Unlock()
8895
return
8996
}
9097
delete(s.m, id)
98+
s.persistLocked()
99+
}
100+
101+
// persistLocked writes the store while the mutex is held. Snapshot-then-
102+
// persist-outside-the-lock let interleaved Set/Delete writes reorder on
103+
// disk: an older snapshot landing last resurrected deleted tokens and
104+
// dropped minted ones, silently breaking session resume.
105+
func (s *Store) persistLocked() {
91106
snapshot := make(map[string]string, len(s.m))
92107
for k, v := range s.m {
93108
snapshot[k] = v
94109
}
95-
path := s.path
96-
s.mu.Unlock()
97-
if err := persist(path, snapshot); err != nil {
110+
if err := persist(s.path, snapshot); err != nil {
98111
warnPersist(err)
99112
}
100113
}

0 commit comments

Comments
 (0)