From 24e4bbe3d16951d0308f71c368160d9d9e23976c Mon Sep 17 00:00:00 2001 From: OpenClaw Dev Date: Sun, 25 Oct 2026 03:14:02 +0100 Subject: [PATCH] feat: HTTP API, WebSocket streaming with token auth and origin check, server entrypoint --- cmd/openclaw/main.go | 92 +++++++++++ go.mod | 18 +- go.sum | 69 ++++---- internal/server/server.go | 291 +++++++++++++++++++++++++++++++++ internal/server/server_test.go | 156 ++++++++++++++++++ 5 files changed, 584 insertions(+), 42 deletions(-) create mode 100644 cmd/openclaw/main.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go diff --git a/cmd/openclaw/main.go b/cmd/openclaw/main.go new file mode 100644 index 0000000..03f2a50 --- /dev/null +++ b/cmd/openclaw/main.go @@ -0,0 +1,92 @@ +// Command openclaw runs the OpenClaw assistant server. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/crypto/openclaw/internal/agent" + "github.com/crypto/openclaw/internal/config" + "github.com/crypto/openclaw/internal/llm" + "github.com/crypto/openclaw/internal/server" + "github.com/crypto/openclaw/internal/store" + "github.com/crypto/openclaw/internal/tools" +) + +var version = "dev" + +func main() { + cfgPath := flag.String("config", os.Getenv("OPENCLAW_CONFIG"), "path to config file (.yaml/.yml/.json)") + showVersion := flag.Bool("version", false, "print version") + flag.Parse() + if *showVersion { + fmt.Println("openclaw", version) + return + } + log := slog.New(slog.NewTextHandler(os.Stderr, nil)) + if err := run(*cfgPath, log); err != nil { + log.Error("fatal", "err", err) + os.Exit(1) + } +} + +func run(cfgPath string, log *slog.Logger) error { + cfg, err := config.Load(cfgPath) + if err != nil { + return err + } + if err := os.MkdirAll(cfg.DataDir, 0o750); err != nil { + return err + } + st, err := store.Open(filepath.Join(cfg.DataDir, "openclaw.db")) + if err != nil { + return err + } + defer st.Close() + + reg := tools.NewRegistry(tools.TimeNow{}) + if cfg.Tools.WebFetch.Enabled { + reg.Register(tools.NewWebFetch(time.Duration(cfg.Tools.WebFetch.TimeoutSec)*time.Second, cfg.Tools.WebFetch.MaxBytes, cfg.Tools.WebFetch.AllowPrivate)) + } + if cfg.Tools.FilesEnabled { + ws := tools.Workspace{Dir: cfg.Workspace, MaxBytes: cfg.Tools.FileMaxBytes} + reg.Register(tools.FileRead{Workspace: ws}) + reg.Register(tools.FileWrite{Workspace: ws}) + reg.Register(tools.FileList{Workspace: ws}) + } + prov := llm.NewOpenAI(cfg.LLM.BaseURL, cfg.LLM.APIKey, cfg.LLM.Model, cfg.LLM.Temperature, time.Duration(cfg.LLM.TimeoutSec)*time.Second) + ag := &agent.Agent{LLM: prov, Tools: reg, Store: st, SystemPrompt: cfg.Agent.SystemPrompt, + MaxIterations: cfg.Agent.MaxIterations, HistoryLimit: cfg.Agent.HistoryLimit} + srv := &server.Server{Agent: ag, Store: st, Token: cfg.AuthToken, AllowedOrigins: cfg.AllowedOrigins, Log: log, + RunTimeout: time.Duration(cfg.LLM.TimeoutSec*cfg.Agent.MaxIterations) * time.Second} + + hs := &http.Server{Addr: cfg.Listen, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 120 * time.Second} + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + errc := make(chan error, 1) + go func() { + log.Info("openclaw listening", "addr", cfg.Listen, "model", cfg.LLM.Model, "tools", len(reg.Specs()), "version", version) + errc <- hs.ListenAndServe() + }() + select { + case err := <-errc: + if !errors.Is(err, http.ErrServerClosed) { + return err + } + case <-ctx.Done(): + log.Info("shutting down") + sctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return hs.Shutdown(sctx) + } + return nil +} diff --git a/go.mod b/go.mod index 2b59647..bcfa17f 100644 --- a/go.mod +++ b/go.mod @@ -1,20 +1,22 @@ module github.com/crypto/openclaw -go 1.26.0 +go 1.25.0 require ( + github.com/coder/websocket v1.8.13 gopkg.in/yaml.v3 v3.0.1 - modernc.org/sqlite v1.61.0 + modernc.org/sqlite v1.38.2 ) require ( - github.com/dustin/go-humanize v1.1.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/mattn/go-isatty v0.0.24 // indirect - github.com/ncruces/go-strftime v1.1.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/sys v0.49.0 // indirect - modernc.org/libc v1.79.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sys v0.34.0 // indirect + modernc.org/libc v1.66.3 // indirect modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.12.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 406773a..bca909b 100644 --- a/go.sum +++ b/go.sum @@ -1,53 +1,54 @@ -github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg= -github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI= -github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs= -github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM= +github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= +github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= -github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= -github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk= -github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= -golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= -golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= -golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= -golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= -golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs= -golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys= -modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= -modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4= -modernc.org/ccgo/v4 v4.37.0/go.mod h1:/8627VhwWtAbqUXmPORxFQU5SgLrAx2jl9seLw9GBhY= -modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= -modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= +modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= +modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o= -modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.79.0 h1:9Dvvv++bAMSkSuBzuOFAatbTV5OIoq6Y6FohmD3e7q8= -modernc.org/libc v1.79.0/go.mod h1:uT3kR61NsbeKJSLqq83GJwSnZMVLlSjyAz4DfTaBy1M= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= +modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= -modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= -modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.61.0 h1:sQi63oLQ9uquAUA4CzOisH35Q3pEszkPBuHM0tFL8CY= -modernc.org/sqlite v1.61.0/go.mod h1:f4gr5tbpx2umklaT/QVtbi/50hNdM+4ng+2zMo8KXJ0= +modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= +modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..1370d9b --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,291 @@ +// Package server exposes the HTTP API and the WebSocket streaming endpoint. +package server + +import ( + "context" + "crypto/subtle" + "encoding/json" + "errors" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + "unicode/utf8" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + + "github.com/crypto/openclaw/internal/agent" + "github.com/crypto/openclaw/internal/store" +) + +const maxBody = 256 << 10 + +type Server struct { + Agent *agent.Agent + Store *store.Store + Token string + AllowedOrigins []string + Log *slog.Logger + RunTimeout time.Duration +} + +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, map[string]string{"status": "ok"}) }) + api := http.NewServeMux() + api.HandleFunc("GET /api/sessions", s.listSessions) + api.HandleFunc("POST /api/sessions", s.createSession) + api.HandleFunc("GET /api/sessions/{id}", s.getSession) + api.HandleFunc("PATCH /api/sessions/{id}", s.renameSession) + api.HandleFunc("DELETE /api/sessions/{id}", s.deleteSession) + api.HandleFunc("GET /api/sessions/{id}/messages", s.listMessages) + api.HandleFunc("POST /api/chat", s.chat) + api.HandleFunc("GET /api/ws", s.ws) + mux.Handle("/api/", s.originCheck(s.auth(api))) + return securityHeaders(mux) +} + +func securityHeaders(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Cache-Control", "no-store") + h.ServeHTTP(w, r) + }) +} + +// auth requires "Authorization: Bearer " (constant-time compare). +func (s *Server) auth(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + if !ok || subtle.ConstantTimeCompare([]byte(got), []byte(s.Token)) != 1 { + writeErr(w, http.StatusUnauthorized, "unauthorized") + return + } + h.ServeHTTP(w, r) + }) +} + +// OriginAllowed: requests without Origin (native apps, curl) pass; browser +// requests must come from the same host or an explicitly configured origin. +func (s *Server) OriginAllowed(r *http.Request) bool { + o := r.Header.Get("Origin") + if o == "" { + return true + } + u, err := url.Parse(o) + if err != nil || u.Host == "" { + return false + } + if strings.EqualFold(u.Host, r.Host) { + return true + } + for _, a := range s.AllowedOrigins { + if a == "*" || strings.EqualFold(strings.TrimRight(a, "/"), o) { + return true + } + } + return false +} + +func (s *Server) originCheck(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !s.OriginAllowed(r) { + writeErr(w, http.StatusForbidden, "origin not allowed") + return + } + if o := r.Header.Get("Origin"); o != "" { + w.Header().Set("Access-Control-Allow-Origin", o) + w.Header().Set("Vary", "Origin") + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE") + w.WriteHeader(http.StatusNoContent) + return + } + } + h.ServeHTTP(w, r) + }) +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(v) +} + +func writeErr(w http.ResponseWriter, code int, msg string) { writeJSON(w, code, map[string]string{"error": msg}) } + +func decode(w http.ResponseWriter, r *http.Request, v any) bool { + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBody)).Decode(v); err != nil { + writeErr(w, http.StatusBadRequest, "invalid JSON body") + return false + } + return true +} + +func (s *Server) storeErr(w http.ResponseWriter, err error) { + if errors.Is(err, store.ErrNotFound) { + writeErr(w, http.StatusNotFound, "not found") + return + } + s.Log.Error("store", "err", err) + writeErr(w, http.StatusInternalServerError, "internal error") +} + +func (s *Server) listSessions(w http.ResponseWriter, r *http.Request) { + list, err := s.Store.ListSessions(r.Context(), 200) + if err != nil { + s.storeErr(w, err) + return + } + writeJSON(w, 200, list) +} + +func (s *Server) createSession(w http.ResponseWriter, r *http.Request) { + var b struct{ Title string `json:"title"` } + if r.ContentLength != 0 && !decode(w, r, &b) { + return + } + ss, err := s.Store.CreateSession(r.Context(), b.Title) + if err != nil { + s.storeErr(w, err) + return + } + writeJSON(w, http.StatusCreated, ss) +} + +func (s *Server) getSession(w http.ResponseWriter, r *http.Request) { + ss, err := s.Store.GetSession(r.Context(), r.PathValue("id")) + if err != nil { + s.storeErr(w, err) + return + } + writeJSON(w, 200, ss) +} + +func (s *Server) renameSession(w http.ResponseWriter, r *http.Request) { + var b struct{ Title string `json:"title"` } + if !decode(w, r, &b) { + return + } + if err := s.Store.RenameSession(r.Context(), r.PathValue("id"), b.Title); err != nil { + s.storeErr(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) deleteSession(w http.ResponseWriter, r *http.Request) { + if err := s.Store.DeleteSession(r.Context(), r.PathValue("id")); err != nil { + s.storeErr(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) listMessages(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if _, err := s.Store.GetSession(r.Context(), id); err != nil { + s.storeErr(w, err) + return + } + msgs, err := s.Store.Messages(r.Context(), id, 0) + if err != nil { + s.storeErr(w, err) + return + } + writeJSON(w, 200, msgs) +} + +type chatReq struct { + Type string `json:"type,omitempty"` + SessionID string `json:"session_id"` + Text string `json:"text"` +} + +// ensureSession returns an existing session id or creates one titled after the message. +func (s *Server) ensureSession(ctx context.Context, id, text string) (string, error) { + if id != "" { + _, err := s.Store.GetSession(ctx, id) + return id, err + } + title := strings.TrimSpace(strings.ReplaceAll(text, "\n", " ")) + if utf8.RuneCountInString(title) > 48 { + title = string([]rune(title)[:48]) + "…" + } + ss, err := s.Store.CreateSession(ctx, title) + return ss.ID, err +} + +func (s *Server) chat(w http.ResponseWriter, r *http.Request) { + var req chatReq + if !decode(w, r, &req) { + return + } + if strings.TrimSpace(req.Text) == "" { + writeErr(w, http.StatusBadRequest, "text required") + return + } + id, err := s.ensureSession(r.Context(), req.SessionID, req.Text) + if err != nil { + s.storeErr(w, err) + return + } + ctx, cancel := context.WithTimeout(r.Context(), s.RunTimeout) + defer cancel() + reply, err := s.Agent.Run(ctx, id, req.Text, nil) + if err != nil { + s.Log.Warn("agent run", "err", err) + writeJSON(w, http.StatusBadGateway, map[string]string{"session_id": id, "error": err.Error()}) + return + } + writeJSON(w, 200, map[string]string{"session_id": id, "reply": reply}) +} + +// ws protocol: client sends {"type":"message","session_id":"...","text":"..."} +// server streams agent.Event JSON objects ending with type "done" or "error". +func (s *Server) ws(w http.ResponseWriter, r *http.Request) { + // Origin was already validated by originCheck; the library's own check is + // therefore disabled here to avoid duplicating (and diverging from) policy. + c, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true}) + if err != nil { + return + } + defer c.CloseNow() + c.SetReadLimit(maxBody) + ctx := r.Context() + for { + var req chatReq + if err := wsjson.Read(ctx, c, &req); err != nil { + return + } + if req.Type == "ping" { + wsjson.Write(ctx, c, agent.Event{Type: "pong"}) + continue + } + if strings.TrimSpace(req.Text) == "" { + wsjson.Write(ctx, c, agent.Event{Type: "error", Text: "text required"}) + continue + } + id, err := s.ensureSession(ctx, req.SessionID, req.Text) + if err != nil { + wsjson.Write(ctx, c, agent.Event{Type: "error", Text: "unknown session"}) + continue + } + wsjson.Write(ctx, c, agent.Event{Type: "session", Session: id}) + runCtx, cancel := context.WithTimeout(ctx, s.RunTimeout) + reply, err := s.Agent.Run(runCtx, id, req.Text, func(e agent.Event) { + e.Session = id + wsjson.Write(runCtx, c, e) + }) + cancel() + if err != nil { + s.Log.Warn("agent run", "err", err) + wsjson.Write(ctx, c, agent.Event{Type: "error", Text: err.Error(), Session: id}) + continue + } + wsjson.Write(ctx, c, agent.Event{Type: "done", Text: reply, Session: id}) + } +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..f7b3175 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,156 @@ +package server + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + + "github.com/crypto/openclaw/internal/agent" + "github.com/crypto/openclaw/internal/llm" + "github.com/crypto/openclaw/internal/store" + "github.com/crypto/openclaw/internal/tools" +) + +const tok = "test-token-0123456789" + +type echo struct{} + +func (echo) Chat(_ context.Context, req llm.Request, d llm.DeltaFunc) (llm.Response, error) { + last := req.Messages[len(req.Messages)-1].Content + for _, w := range strings.Fields("echo: " + last) { + d(w + " ") + } + return llm.Response{Content: "echo: " + last}, nil +} + +func setup(t *testing.T, origins ...string) *httptest.Server { + st, err := store.Open(filepath.Join(t.TempDir(), "s.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + s := &Server{Store: st, Token: tok, AllowedOrigins: origins, Log: slog.New(slog.NewTextHandler(io.Discard, nil)), RunTimeout: 5 * time.Second, + Agent: &agent.Agent{LLM: echo{}, Store: st, Tools: tools.NewRegistry(), MaxIterations: 3}} + ts := httptest.NewServer(s.Handler()) + t.Cleanup(ts.Close) + return ts +} + +func do(t *testing.T, method, url, body string, hdr map[string]string) *http.Response { + req, _ := http.NewRequest(method, url, strings.NewReader(body)) + for k, v := range hdr { + req.Header.Set(k, v) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { resp.Body.Close() }) + return resp +} + +var authH = map[string]string{"Authorization": "Bearer " + tok} + +func TestAuthAndOrigin(t *testing.T) { + ts := setup(t, "https://app.example") + if r := do(t, "GET", ts.URL+"/healthz", "", nil); r.StatusCode != 200 { + t.Fatal("healthz", r.StatusCode) + } + if r := do(t, "GET", ts.URL+"/api/sessions", "", nil); r.StatusCode != 401 { + t.Fatal("no token", r.StatusCode) + } + if r := do(t, "GET", ts.URL+"/api/sessions", "", map[string]string{"Authorization": "Bearer wrong"}); r.StatusCode != 401 { + t.Fatal("bad token", r.StatusCode) + } + evil := map[string]string{"Authorization": "Bearer " + tok, "Origin": "https://evil.example"} + if r := do(t, "GET", ts.URL+"/api/sessions", "", evil); r.StatusCode != 403 { + t.Fatal("evil origin", r.StatusCode) + } + ok := map[string]string{"Authorization": "Bearer " + tok, "Origin": "https://app.example"} + if r := do(t, "GET", ts.URL+"/api/sessions", "", ok); r.StatusCode != 200 { + t.Fatal("allowed origin", r.StatusCode) + } + same := map[string]string{"Authorization": "Bearer " + tok, "Origin": ts.URL} + if r := do(t, "GET", ts.URL+"/api/sessions", "", same); r.StatusCode != 200 { + t.Fatal("same origin", r.StatusCode) + } +} + +func TestChatAndSessions(t *testing.T) { + ts := setup(t) + r := do(t, "POST", ts.URL+"/api/chat", `{"text":"hello world"}`, authH) + var out map[string]string + json.NewDecoder(r.Body).Decode(&out) + if r.StatusCode != 200 || out["reply"] != "echo: hello world" || out["session_id"] == "" { + t.Fatalf("%d %v", r.StatusCode, out) + } + r = do(t, "GET", ts.URL+"/api/sessions", "", authH) + var list []store.Session + json.NewDecoder(r.Body).Decode(&list) + if len(list) != 1 || list[0].Title != "hello world" { + t.Fatalf("%+v", list) + } + r = do(t, "GET", ts.URL+"/api/sessions/"+out["session_id"]+"/messages", "", authH) + var msgs []store.StoredMessage + json.NewDecoder(r.Body).Decode(&msgs) + if len(msgs) != 2 { + t.Fatalf("msgs %+v", msgs) + } + if r := do(t, "DELETE", ts.URL+"/api/sessions/"+out["session_id"], "", authH); r.StatusCode != 204 { + t.Fatal(r.StatusCode) + } + if r := do(t, "GET", ts.URL+"/api/sessions/nope/messages", "", authH); r.StatusCode != 404 { + t.Fatal(r.StatusCode) + } +} + +func TestWebSocketStreaming(t *testing.T) { + ts := setup(t) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/ws" + if _, _, err := websocket.Dial(ctx, wsURL, nil); err == nil { + t.Fatal("ws without token must fail") + } + if _, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: http.Header{"Authorization": {"Bearer " + tok}, "Origin": {"https://evil.example"}}}); err == nil { + t.Fatal("ws from foreign origin must fail") + } + c, _, err := websocket.Dial(ctx, wsURL, &websocket.DialOptions{HTTPHeader: http.Header{"Authorization": {"Bearer " + tok}}}) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + wsjson.Write(ctx, c, map[string]string{"type": "message", "text": "hi there"}) + var deltas strings.Builder + var sid string + for { + var e agent.Event + if err := wsjson.Read(ctx, c, &e); err != nil { + t.Fatal(err) + } + switch e.Type { + case "session": + sid = e.Session + case "delta": + deltas.WriteString(e.Text) + case "error": + t.Fatal(e.Text) + } + if e.Type == "done" { + if e.Text != "echo: hi there" || sid == "" || e.Session != sid || strings.TrimSpace(deltas.String()) != "echo: hi there" { + t.Fatalf("done %+v deltas %q", e, deltas.String()) + } + return + } + } +}