Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Build artifacts
/bin/
/bodek
/coverage.out

# Editor / OS noise
.DS_Store
Expand Down
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 8 additions & 5 deletions cmd/bodek/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
)
Expand All @@ -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()

Expand Down Expand Up @@ -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,
Expand Down
25 changes: 14 additions & 11 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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
}
Expand Down
56 changes: 56 additions & 0 deletions internal/client/client_ws_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"

Expand Down Expand Up @@ -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) {}))
Expand Down
3 changes: 3 additions & 0 deletions internal/client/rest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading