Skip to content

Commit ff6db5e

Browse files
committed
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.
1 parent 61ef1ff commit ff6db5e

8 files changed

Lines changed: 445 additions & 47 deletions

File tree

README.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,21 @@ bodek looks for `odek` on your `PATH`. To point at a specific binary use
6969
## Usage
7070

7171
```bash
72-
bodek # launch odek serve and start chatting
73-
bodek --sandbox # run tool calls inside odek's Docker sandbox
74-
bodek --url http://127.0.0.1:8080 # attach to an already-running odek serve
75-
bodek --odek-bin ./odek # use a specific odek binary
76-
bodek -- --prompt-caching # pass extra flags through to `odek serve`
72+
bodek # launch odek serve and start chatting
73+
bodek --sandbox # run tool calls inside odek's Docker sandbox
74+
bodek --url 'http://127.0.0.1:8080/?token=…' # attach with the token URL odek serve printed
75+
bodek --url http://127.0.0.1:8080 --token d3adb33f # attach with an explicit token
76+
bodek --odek-bin ./odek # use a specific odek binary
77+
bodek -- --prompt-caching # pass extra flags through to `odek serve`
7778
```
7879

80+
`odek serve` protects its WebSocket and REST APIs with a per-instance token it
81+
prints to stderr at startup (`odek serve ⚡ http://…/?token=…`). When bodek
82+
spawns the server it picks the token up automatically; when you attach to one
83+
that's already running, paste the token URL into `--url` or pass the token
84+
itself via `--token`. Older odek versions without token enforcement are
85+
detected and connected to as before.
86+
7987
Configuration (model, base URL, API key, MCP servers, memory, skills) is read
8088
by `odek serve` from its usual chain — `~/.odek/config.json``./odek.json`
8189
`ODEK_*` env vars — so bodek inherits whatever you've already set up.

cmd/bodek/main.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ func main() {
2828

2929
func run() error {
3030
var (
31-
url = flag.String("url", "", "attach to an already-running odek serve URL (e.g. http://127.0.0.1:8080)")
31+
url = flag.String("url", "", "attach to an already-running odek serve URL (e.g. http://127.0.0.1:8080/?token=…)")
32+
token = flag.String("token", "", "WS auth token for an attached odek serve (as printed at its startup)")
3233
sandbox = flag.Bool("sandbox", false, "run tool calls inside odek's Docker sandbox")
3334
bin = flag.String("odek-bin", "", "path to the odek binary to spawn (default: odek on PATH)")
3435
)
@@ -38,10 +39,11 @@ func run() error {
3839
fmt.Fprintf(os.Stderr, "Options:\n")
3940
flag.PrintDefaults()
4041
fmt.Fprintf(os.Stderr, "\nExamples:\n")
41-
fmt.Fprintf(os.Stderr, " bodek # spawn odek serve and start chatting\n")
42-
fmt.Fprintf(os.Stderr, " bodek --sandbox # spawn odek serve with Docker sandbox\n")
43-
fmt.Fprintf(os.Stderr, " bodek --url http://127.0.0.1:8080 # attach to a running odek serve\n")
44-
fmt.Fprintf(os.Stderr, " bodek -- --prompt-caching # pass extra flags to odek serve\n")
42+
fmt.Fprintf(os.Stderr, " bodek # spawn odek serve and start chatting\n")
43+
fmt.Fprintf(os.Stderr, " bodek --sandbox # spawn odek serve with Docker sandbox\n")
44+
fmt.Fprintf(os.Stderr, " bodek --url 'http://127.0.0.1:8080/?token=…' # attach with the token URL odek serve printed\n")
45+
fmt.Fprintf(os.Stderr, " bodek --url http://127.0.0.1:8080 --token d3adb33f # attach with an explicit token\n")
46+
fmt.Fprintf(os.Stderr, " bodek -- --prompt-caching # pass extra flags to odek serve\n")
4547
}
4648
flag.Parse()
4749

@@ -74,6 +76,7 @@ func run() error {
7476
// Spawn or attach to the odek serve backend.
7577
srv, err := server.Connect(server.Options{
7678
URL: *url,
79+
Token: *token,
7780
Bin: *bin,
7881
Sandbox: *sandbox,
7982
ExtraArgs: extraArgs,

internal/client/client.go

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -78,16 +78,18 @@ type Resource struct {
7878

7979
// Client is a connected odek serve session.
8080
type Client struct {
81-
conn *ws.Conn
82-
baseURL string
83-
http *http.Client
84-
Events chan Event
81+
conn *ws.Conn
82+
baseURL string
83+
serveToken string
84+
http *http.Client
85+
Events chan Event
8586
}
8687

8788
// Dial connects to an odek serve WebSocket. wsURL is the ws:// endpoint,
8889
// origin is an http://localhost-based origin accepted by the server, baseURL is
89-
// the http:// root (used for the resource-search API), and token is the
90-
// per-instance CSRF token (obtained from a GET / Set-Cookie header).
90+
// the http:// root (used for the REST APIs), and token is the per-instance
91+
// CSRF token resolved by server.Connect (token URL, stderr banner, or legacy
92+
// cookie). It is sent on the WS handshake and on every REST request.
9193
func Dial(wsURL, origin, baseURL, token string) (*Client, error) {
9294
cfg, err := ws.NewConfig(wsURL, origin)
9395
if err != nil {
@@ -101,10 +103,11 @@ func Dial(wsURL, origin, baseURL, token string) (*Client, error) {
101103
}
102104

103105
c := &Client{
104-
conn: conn,
105-
baseURL: baseURL,
106-
http: &http.Client{Timeout: 3 * time.Second},
107-
Events: make(chan Event, 256),
106+
conn: conn,
107+
baseURL: baseURL,
108+
serveToken: token,
109+
http: &http.Client{Timeout: 3 * time.Second},
110+
Events: make(chan Event, 256),
108111
}
109112
go c.readLoop()
110113
return c, nil
@@ -114,7 +117,7 @@ func Dial(wsURL, origin, baseURL, token string) (*Client, error) {
114117
func (c *Client) Resources(query string, limit int) ([]Resource, error) {
115118
u := fmt.Sprintf("%s/api/resources?q=%s&limit=%d",
116119
c.baseURL, url.QueryEscape(query), limit)
117-
resp, err := c.http.Get(u)
120+
resp, err := c.do(http.MethodGet, u, "")
118121
if err != nil {
119122
return nil, err
120123
}

internal/client/client_ws_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"net/http"
66
"net/http/httptest"
77
"strings"
8+
"sync"
89
"testing"
910
"time"
1011

@@ -201,6 +202,61 @@ func TestRESTEndpoints(t *testing.T) {
201202
}
202203
}
203204

205+
func TestRESTCarriesServeToken(t *testing.T) {
206+
// Every REST request must carry the per-instance serve token, mirroring
207+
// odek serve's requireServeToken (cookie or X-Odek-Ws-Token header).
208+
var seen []string
209+
var mu sync.Mutex
210+
record := func(w http.ResponseWriter, r *http.Request) {
211+
mu.Lock()
212+
seen = append(seen, r.URL.Path+":"+r.Header.Get("X-Odek-Ws-Token"))
213+
mu.Unlock()
214+
}
215+
mux := http.NewServeMux()
216+
mux.Handle("/ws", ws.Handler(func(c *ws.Conn) { _, _ = c.Write(nil) }))
217+
mux.HandleFunc("/api/sessions", func(w http.ResponseWriter, r *http.Request) {
218+
record(w, r)
219+
json.NewEncoder(w).Encode([]Session{})
220+
})
221+
mux.HandleFunc("/api/models", func(w http.ResponseWriter, r *http.Request) {
222+
record(w, r)
223+
json.NewEncoder(w).Encode([]ModelInfo{})
224+
})
225+
mux.HandleFunc("/api/resources", func(w http.ResponseWriter, r *http.Request) {
226+
record(w, r)
227+
json.NewEncoder(w).Encode([]Resource{})
228+
})
229+
mux.HandleFunc("/api/cancel", func(w http.ResponseWriter, r *http.Request) {
230+
record(w, r)
231+
w.WriteHeader(http.StatusNoContent)
232+
})
233+
cl, _ := newTestServer(t, mux)
234+
235+
if _, err := cl.Sessions(); err != nil {
236+
t.Fatalf("Sessions: %v", err)
237+
}
238+
if _, err := cl.Models(); err != nil {
239+
t.Fatalf("Models: %v", err)
240+
}
241+
if _, err := cl.Resources("x", 1); err != nil {
242+
t.Fatalf("Resources: %v", err)
243+
}
244+
if err := cl.Cancel("s1", "tok"); err != nil {
245+
t.Fatalf("Cancel: %v", err)
246+
}
247+
248+
mu.Lock()
249+
defer mu.Unlock()
250+
if len(seen) != 4 {
251+
t.Fatalf("expected 4 API calls, got %v", seen)
252+
}
253+
for _, s := range seen {
254+
if !strings.HasSuffix(s, ":test-token") {
255+
t.Errorf("request missing serve token header: %q", s)
256+
}
257+
}
258+
}
259+
204260
func TestRESTErrorStatuses(t *testing.T) {
205261
mux := http.NewServeMux()
206262
mux.Handle("/ws", ws.Handler(func(c *ws.Conn) {}))

internal/client/rest.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ func (c *Client) do(method, u, sessionToken string) (*http.Response, error) {
110110
if err != nil {
111111
return nil, err
112112
}
113+
if c.serveToken != "" {
114+
req.Header.Set("X-Odek-Ws-Token", c.serveToken)
115+
}
113116
if sessionToken != "" {
114117
req.Header.Set("X-Session-Token", sessionToken)
115118
}

0 commit comments

Comments
 (0)