From 250af7967f51afa277272d8d172ddeb524bf5169 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Mon, 6 Jul 2026 18:22:38 +0200 Subject: [PATCH] update httpgw: add dbus and ninep support --- dbus.go | 649 +++++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 5 +- go.sum | 2 + main.go | 495 ++++----------------------------------- ninep.go | 434 ++++++++++++++++++++++++++++++++++ openapi.json | 384 +++++++++++++++++++++--------- 6 files changed, 1413 insertions(+), 556 deletions(-) create mode 100644 dbus.go create mode 100644 ninep.go diff --git a/dbus.go b/dbus.go new file mode 100644 index 0000000..9dfc7c7 --- /dev/null +++ b/dbus.go @@ -0,0 +1,649 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strconv" + "strings" + + "github.com/godbus/dbus/v5" +) + +const ( + dbusService = "org.ollie.SessionManager" + dbusPath = "/org/ollie/SessionManager" + dbusIface = "org.ollie.SessionManager" +) + +func dbusConn() (*dbus.Conn, error) { + return dbus.ConnectSessionBus() +} + +func dbusCall(method string, args ...interface{}) (*dbus.Call, error) { + conn, err := dbusConn() + if err != nil { + return nil, fmt.Errorf("dbus connect: %w", err) + } + defer conn.Close() + + obj := conn.Object(dbusService, dbusPath) + call := obj.Call(dbusIface+"."+method, 0, args...) + if call.Err != nil { + return nil, call.Err + } + return call, nil +} + +// dbusHandler routes HTTP requests to D-Bus method calls. +// +// API: +// +// GET /sessions - ListSessions +// POST /sessions - CreateSession +// DELETE /sessions/{id} - KillSession +// PATCH /sessions/{id} - RenameSession +// +// POST /sessions/{id}/submit - Submit prompt +// POST /sessions/{id}/interrupt - Interrupt +// +// GET /sessions/{id}/state - GetState +// GET /sessions/{id}/chat - GetChat (query: offset) +// GET /sessions/{id}/usage - GetUsage +// GET /sessions/{id}/cost - GetCost +// GET /sessions/{id}/config - GetConfig +// POST /sessions/{id}/config - SetConfig +// GET /sessions/{id}/context - GetContext +// +// GET /backends - ListBackends +// GET /sessions/{id}/models - ListModels +// GET /agents - ListAgents +// +// GET /sessions/{id}/peers - PeerList +// POST /sessions/{id}/peers - PeerAdd +// DELETE /sessions/{id}/peers/{peer_id} - PeerRemove +// POST /sessions/{id}/peers/{peer_id}/submit - PeerSubmit +func dbusHandler(w http.ResponseWriter, r *http.Request) { + p := strings.TrimPrefix(r.URL.Path, "/") + p = strings.TrimSuffix(p, "/") + + parts := strings.Split(p, "/") + + switch { + // GET/POST /sessions + case len(parts) == 1 && parts[0] == "sessions": + switch r.Method { + case http.MethodGet: + dbusListSessions(w) + case http.MethodPost: + dbusCreateSession(w, r) + default: + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + } + + // /sessions/{id} + case len(parts) == 2 && parts[0] == "sessions": + sessionID := parts[1] + switch r.Method { + case http.MethodGet: + dbusGetSession(w, sessionID) + case http.MethodDelete: + dbusKillSession(w, sessionID) + case http.MethodPatch: + dbusRenameSession(w, r, sessionID) + default: + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + } + + // /sessions/{id}/{action} + case len(parts) == 3 && parts[0] == "sessions": + sessionID := parts[1] + action := parts[2] + switch action { + case "submit": + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST only"}) + return + } + dbusSubmit(w, r, sessionID) + case "interrupt": + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST only"}) + return + } + dbusInterrupt(w, sessionID) + case "state": + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"}) + return + } + dbusGetState(w, sessionID) + case "chat": + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"}) + return + } + dbusGetChat(w, r, sessionID) + case "usage": + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"}) + return + } + dbusGetUsage(w, sessionID) + case "cost": + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"}) + return + } + dbusGetCost(w, sessionID) + case "config": + switch r.Method { + case http.MethodGet: + dbusGetConfig(w, sessionID) + case http.MethodPost: + dbusSetConfig(w, r, sessionID) + default: + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET or POST"}) + } + case "context": + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"}) + return + } + dbusGetContext(w, sessionID) + case "models": + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"}) + return + } + dbusListModels(w, sessionID) + case "peers": + switch r.Method { + case http.MethodGet: + dbusPeerList(w, sessionID) + case http.MethodPost: + dbusPeerAdd(w, r, sessionID) + default: + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET or POST"}) + } + default: + writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) + } + + // /sessions/{id}/peers/{peer_id} + case len(parts) == 4 && parts[0] == "sessions" && parts[2] == "peers": + sessionID := parts[1] + peerID := parts[3] + switch r.Method { + case http.MethodDelete: + dbusPeerRemove(w, sessionID, peerID) + default: + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "DELETE only"}) + } + + // /sessions/{id}/peers/{peer_id}/submit + case len(parts) == 5 && parts[0] == "sessions" && parts[2] == "peers" && parts[4] == "submit": + sessionID := parts[1] + peerID := parts[3] + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST only"}) + return + } + dbusPeerSubmit(w, r, sessionID, peerID) + + // GET /backends + case len(parts) == 1 && parts[0] == "backends": + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"}) + return + } + dbusListBackends(w) + + // GET /agents + case len(parts) == 1 && parts[0] == "agents": + if r.Method != http.MethodGet { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"}) + return + } + dbusListAgents(w) + + default: + writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) + } +} + +// --- Sessions --- + +func dbusListSessions(w http.ResponseWriter) { + call, err := dbusCall("ListSessions") + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + var sessions []string + if err := call.Store(&sessions); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + var result []map[string]string + for _, entry := range sessions { + parts := strings.SplitN(entry, "\t", 4) + if len(parts) >= 4 { + result = append(result, map[string]string{ + "id": parts[0], + "state": parts[1], + "model": parts[2], + "agent": parts[3], + }) + } + } + if result == nil { + result = []map[string]string{} + } + sort.Slice(result, func(i, j int) bool { + return result[i]["id"] < result[j]["id"] + }) + writeJSON(w, http.StatusOK, result) +} + +func dbusCreateSession(w http.ResponseWriter, r *http.Request) { + var req struct { + CWD string `json:"cwd"` + Backend string `json:"backend"` + Model string `json:"model"` + Agent string `json:"agent"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + + call, err := dbusCall("CreateSession", req.CWD, req.Backend, req.Model, req.Agent) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + var sessionID string + if err := call.Store(&sessionID); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if sessionID == "" { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "session creation failed"}) + return + } + writeJSON(w, http.StatusCreated, map[string]string{"id": sessionID}) +} + +func dbusGetSession(w http.ResponseWriter, sessionID string) { + // Return config as the session detail view + dbusGetConfig(w, sessionID) +} + +func dbusKillSession(w http.ResponseWriter, sessionID string) { + call, err := dbusCall("KillSession", sessionID) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + var success bool + if err := call.Store(&success); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if !success { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "session not found"}) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func dbusRenameSession(w http.ResponseWriter, r *http.Request, sessionID string) { + var req struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + call, err := dbusCall("RenameSession", sessionID, req.Name) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + var success bool + if err := call.Store(&success); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if !success { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "rename failed"}) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// --- Interaction --- + +func dbusSubmit(w http.ResponseWriter, r *http.Request, sessionID string) { + var req struct { + Prompt string `json:"prompt"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + call, err := dbusCall("Submit", sessionID, req.Prompt) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + var success bool + if err := call.Store(&success); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if !success { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "session not found"}) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"submitted": true}) +} + +func dbusInterrupt(w http.ResponseWriter, sessionID string) { + call, err := dbusCall("Interrupt", sessionID) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + var success bool + if err := call.Store(&success); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if !success { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "session not found"}) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"interrupted": true}) +} + +// --- State queries --- + +func dbusGetState(w http.ResponseWriter, sessionID string) { + call, err := dbusCall("GetState", sessionID) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + var state string + if err := call.Store(&state); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"state": state}) +} + +func dbusGetChat(w http.ResponseWriter, r *http.Request, sessionID string) { + offsetStr := r.URL.Query().Get("offset") + var offset int64 + if offsetStr != "" { + offset, _ = strconv.ParseInt(offsetStr, 10, 64) + } + + conn, err := dbusConn() + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + defer conn.Close() + + obj := conn.Object(dbusService, dbusPath) + call := obj.Call(dbusIface+".GetChat", 0, sessionID, offset) + if call.Err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": call.Err.Error()}) + return + } + + var text string + var newOffset int64 + if err := call.Store(&text, &newOffset); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Chat-Offset", strconv.FormatInt(newOffset, 10)) + w.Write([]byte(text)) +} + +func dbusGetUsage(w http.ResponseWriter, sessionID string) { + call, err := dbusCall("GetUsage", sessionID) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + var usage string + if err := call.Store(&usage); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"usage": usage}) +} + +func dbusGetCost(w http.ResponseWriter, sessionID string) { + call, err := dbusCall("GetCost", sessionID) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + var cost string + if err := call.Store(&cost); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"cost": cost}) +} + +// --- Config --- + +func dbusGetConfig(w http.ResponseWriter, sessionID string) { + call, err := dbusCall("GetConfig", sessionID) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + var config string + if err := call.Store(&config); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + // Parse key=value\n into JSON object + m := make(map[string]string) + for _, line := range strings.Split(strings.TrimRight(config, "\n"), "\n") { + if line == "" { + continue + } + k, v, _ := strings.Cut(line, "=") + m[k] = v + } + writeJSON(w, http.StatusOK, m) +} + +func dbusSetConfig(w http.ResponseWriter, r *http.Request, sessionID string) { + var req struct { + Key string `json:"key"` + Value string `json:"value"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + call, err := dbusCall("SetConfig", sessionID, req.Key, req.Value) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + var success bool + if err := call.Store(&success); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if !success { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "set config failed"}) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"success": true}) +} + +// --- Context --- + +func dbusGetContext(w http.ResponseWriter, sessionID string) { + call, err := dbusCall("GetContext", sessionID) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + var context string + if err := call.Store(&context); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + // Already JSONL — return as-is with appropriate content type + w.Header().Set("Content-Type", "application/x-ndjson") + w.Write([]byte(context)) +} + +// --- Backends/Models/Agents --- + +func dbusListBackends(w http.ResponseWriter) { + call, err := dbusCall("ListBackends") + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + var backends []string + if err := call.Store(&backends); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, backends) +} + +func dbusListModels(w http.ResponseWriter, sessionID string) { + call, err := dbusCall("ListModels", sessionID) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + var models []string + if err := call.Store(&models); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, models) +} + +func dbusListAgents(w http.ResponseWriter) { + call, err := dbusCall("ListAgents") + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + var agents []string + if err := call.Store(&agents); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, agents) +} + +// --- Peers --- + +func dbusPeerList(w http.ResponseWriter, sessionID string) { + call, err := dbusCall("PeerList", sessionID) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + var peers []string + if err := call.Store(&peers); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if peers == nil { + peers = []string{} + } + writeJSON(w, http.StatusOK, peers) +} + +func dbusPeerAdd(w http.ResponseWriter, r *http.Request, sessionID string) { + var req struct { + PeerID string `json:"peer_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + call, err := dbusCall("PeerAdd", sessionID, req.PeerID) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + var success bool + if err := call.Store(&success); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if !success { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "peer add failed"}) + return + } + writeJSON(w, http.StatusCreated, map[string]bool{"added": true}) +} + +func dbusPeerRemove(w http.ResponseWriter, sessionID, peerID string) { + call, err := dbusCall("PeerRemove", sessionID, peerID) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + var success bool + if err := call.Store(&success); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if !success { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "peer not found"}) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func dbusPeerSubmit(w http.ResponseWriter, r *http.Request, sessionID, peerID string) { + var req struct { + Prompt string `json:"prompt"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + call, err := dbusCall("PeerSubmit", sessionID, peerID, req.Prompt) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + var success bool + if err := call.Store(&success); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if !success { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "peer submit failed"}) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"submitted": true}) +} diff --git a/go.mod b/go.mod index ceebf20..0eb19fd 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,7 @@ module ollie-httpgw go 1.25.6 -require 9fans.net/go v0.0.7 +require ( + 9fans.net/go v0.0.7 + github.com/godbus/dbus/v5 v5.1.0 +) diff --git a/go.sum b/go.sum index d81a876..72f69f9 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= diff --git a/main.go b/main.go index d385d53..5e02d7b 100644 --- a/main.go +++ b/main.go @@ -1,13 +1,15 @@ -// ollie-httpgw - HTTP gateway for the ollie 9P server. +// ollie-httpgw - HTTP gateway for ollie session management. // -// Maps HTTP methods to 9P file operations: +// Supports two backend modes: // -// GET /path - read file content or list directory entries -// PUT /path - write request body to file (truncate) -// POST /path - create file (name = last path element) -// DELETE /path - remove file -// HEAD /path - stat file (metadata in headers) -// PATCH /path - wstat (rename via JSON {"name": "newname"}) +// dbus (default) - translates HTTP to D-Bus calls against org.ollie.SessionManager +// 9p - translates HTTP to 9P file operations +// +// Usage: +// +// ollie-httpgw [start|fgstart|stop|status] # dbus mode (default) +// ollie-httpgw 9p [start|fgstart|stop|status] # 9p mode +// ollie-httpgw dbus [start|fgstart|stop|status] # explicit dbus mode package main import ( @@ -15,18 +17,14 @@ import ( "encoding/json" "flag" "fmt" - "io" "log" "net" "net/http" "os" "os/exec" - "path" "path/filepath" - "strings" "syscall" - "9fans.net/go/plan9" "9fans.net/go/plan9/client" ) @@ -34,7 +32,7 @@ import ( var openapiSpec []byte var ( - listen = flag.String("listen", ":8080", "HTTP listen address") + listen = flag.String("listen", ":8011", "HTTP listen address") service = flag.String("service", "ollie", "9P service name to connect to") network = flag.String("net", "", "network type (unix, tcp); empty = use namespace") addr = flag.String("addr", "", "9P server address; empty = use namespace service") @@ -42,16 +40,32 @@ var ( const pidFile = "ollie-httpgw.pid" +// backendMode tracks which backend we're using. +var backendMode = "dbus" + func main() { - if len(os.Args) >= 2 { - subcmd := os.Args[1] + args := os.Args[1:] + + // Parse optional mode prefix: "dbus" or "9p" + if len(args) > 0 { + switch args[0] { + case "dbus": + backendMode = "dbus" + args = args[1:] + case "9p": + backendMode = "9p" + args = args[1:] + } + } + + if len(args) >= 1 { + subcmd := args[0] switch subcmd { case "start", "fgstart", "stop", "status": - flag.CommandLine.Parse(os.Args[2:]) //nolint:errcheck + flag.CommandLine.Parse(args[1:]) //nolint:errcheck ns := client.Namespace() if ns == "" { - fmt.Fprintln(os.Stderr, "no namespace") - os.Exit(1) + ns = os.TempDir() } pidPath := filepath.Join(ns, pidFile) switch subcmd { @@ -60,7 +74,7 @@ func main() { fmt.Println("ollie-httpgw already running") os.Exit(0) } - daemonize() + daemonize(args) case "fgstart": if gwRunning() { fmt.Println("ollie-httpgw already running") @@ -80,8 +94,9 @@ func main() { return } } + // Legacy: no subcommand — run in foreground directly (no pid file). - flag.Parse() + flag.CommandLine.Parse(args) //nolint:errcheck serve() } @@ -94,9 +109,9 @@ func gwRunning() bool { return false } -func daemonize() { +func daemonize(subcmdArgs []string) { exe, _ := os.Executable() - args := []string{"fgstart", "-listen", *listen, "-service", *service} + args := []string{backendMode, "fgstart", "-listen", *listen, "-service", *service} if *network != "" { args = append(args, "-net", *network) } @@ -109,7 +124,7 @@ func daemonize() { fmt.Fprintf(os.Stderr, "failed to start: %v\n", err) os.Exit(1) } - fmt.Printf("ollie-httpgw started (pid %d)\n", cmd.Process.Pid) + fmt.Printf("ollie-httpgw started (pid %d, mode %s)\n", cmd.Process.Pid, backendMode) } func stopGateway(pidPath string) { @@ -138,434 +153,22 @@ func serve() { w.Header().Set("Content-Type", "application/json") w.Write(openapiSpec) }) - http.HandleFunc("/", handler) - log.Printf("ollie-httpgw listening on %s", *listen) + + switch backendMode { + case "dbus": + http.HandleFunc("/", dbusHandler) + case "9p": + http.HandleFunc("/", ninepHandler) + default: + log.Fatalf("unknown backend mode: %s", backendMode) + } + + log.Printf("ollie-httpgw listening on %s (mode: %s)", *listen, backendMode) log.Fatal(http.ListenAndServe(*listen, nil)) } -func dial() (*client.Fsys, error) { - if *addr != "" { - n := *network - if n == "" { - n = "tcp" - } - return client.Mount(n, *addr) - } - return client.MountService(*service) -} - -type dirEntry struct { - Name string `json:"name"` - Size uint64 `json:"size"` - Mode uint32 `json:"mode"` - Mtime uint32 `json:"mtime"` - IsDir bool `json:"is_dir"` -} - -func dirToEntry(d *plan9.Dir) dirEntry { - return dirEntry{ - Name: d.Name, - Size: d.Length, - Mode: uint32(d.Mode), - Mtime: d.Mtime, - IsDir: d.Mode&plan9.DMDIR != 0, - } -} - -func setStatHeaders(w http.ResponseWriter, d *plan9.Dir) { - w.Header().Set("X-9p-Name", d.Name) - w.Header().Set("X-9p-Size", fmt.Sprintf("%d", d.Length)) - w.Header().Set("X-9p-Mode", fmt.Sprintf("%o", d.Mode)) - w.Header().Set("X-9p-Mtime", fmt.Sprintf("%d", d.Mtime)) - if d.Mode&plan9.DMDIR != 0 { - w.Header().Set("X-9p-Type", "dir") - } else { - w.Header().Set("X-9p-Type", "file") - } -} - func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } - -func handler(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/open" { - handleOpen(w, r) - return - } - - fsys, err := dial() - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": "9p connect: " + err.Error()}) - return - } - defer fsys.Close() - - p := strings.TrimPrefix(r.URL.Path, "/") - - // Internal paths not exposed via HTTP. - first, _, _ := strings.Cut(p, "/") - // Paths never exposed via HTTP. - switch first { - case "p", "help", "tr", "u", "x", "tmp": - writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) - return - } - // Read-only paths — only GET and HEAD allowed. - switch first { - case "", "sk": - if r.Method != http.MethodGet && r.Method != http.MethodHead { - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "read-only"}) - return - } - } - base := path.Base(p) - switch base { - case "systemprompt", "statewait": - writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) - return - case "new": - if r.Method == http.MethodPost { - handleWrite(w, r, fsys, p) - return - } - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) - return - } - - switch r.Method { - case http.MethodGet: - handleRead(w, fsys, p) - case http.MethodHead: - handleStat(w, fsys, p) - case http.MethodPost: - if base == "chat" { - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "chat is read-only"}) - return - } - handleWrite(w, r, fsys, p) - case http.MethodPut: - handlePut(w, r, fsys, p) - case http.MethodDelete: - handleRemove(w, fsys, p) - case http.MethodPatch: - handleWstat(w, r, fsys, p) - case http.MethodOptions: - handleOptions(w, r, p) - default: - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) - } -} - -var allowedMethods = "GET, HEAD, PUT, POST, DELETE, PATCH, OPTIONS" - -var methodDescriptions = map[string]string{ - "GET": "read file content or list directory entries", - "HEAD": "stat file (metadata in response headers)", - "PUT": "write raw body to file (truncate)", - "POST": "write to file or trigger action", - "DELETE": "remove file or session", - "PATCH": "rename via JSON {\"name\": \"newname\"}", - "OPTIONS": "describe available operations for this path", -} - -func handleOptions(w http.ResponseWriter, _ *http.Request, p string) { - w.Header().Set("Allow", allowedMethods) - writeJSON(w, http.StatusOK, map[string]any{ - "path": "/" + p, - "methods": methodDescriptions, - }) -} - -func handleRead(w http.ResponseWriter, fsys *client.Fsys, p string) { - d, err := fsys.Stat(p) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return - } - setStatHeaders(w, d) - - if d.Mode&plan9.DMDIR != 0 { - fid, err := fsys.Open(p, plan9.OREAD) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - defer fid.Close() - dirs, err := fid.Dirreadall() - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if p == "s" { - var ids []string - for _, de := range dirs { - if de.Mode&plan9.DMDIR != 0 { - ids = append(ids, de.Name) - } - } - writeJSON(w, http.StatusOK, ids) - return - } - entries := make([]dirEntry, len(dirs)) - for i, de := range dirs { - entries[i] = dirToEntry(de) - } - writeJSON(w, http.StatusOK, entries) - return - } - - fid, err := fsys.Open(p, plan9.OREAD) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - defer fid.Close() - content, err := io.ReadAll(fid) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if p == "models" { - writeJSON(w, http.StatusOK, parseModels(content)) - return - } - if p == "s/idx" { - writeJSON(w, http.StatusOK, parseIdx(content)) - return - } - if path.Base(p) == "chat" { - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.Write(content) - return - } - if path.Base(p) == "cfg" { - m := parseContent(content, "cfg") - if mm, ok := m.(map[string]string); ok { - // Inject state from sibling file. - stateFile := path.Dir(p) + "/state" - if sf, err := fsys.Open(stateFile, plan9.OREAD); err == nil { - if sb, err := io.ReadAll(sf); err == nil { - mm["state"] = strings.TrimSpace(string(sb)) - } - sf.Close() - } - } - writeJSON(w, http.StatusOK, m) - return - } - writeJSON(w, http.StatusOK, parseContent(content, path.Base(p))) -} - -type idxEntry struct { - ID string `json:"id"` - State string `json:"state"` - CWD string `json:"cwd"` - Backend string `json:"backend"` - Model string `json:"model"` -} - -type modelsEntry struct { - Backend string `json:"backend"` - Models []string `json:"models"` -} - -func parseModels(data []byte) []modelsEntry { - groups := make(map[string][]string) - var order []string - for line := range strings.SplitSeq(strings.TrimRight(string(data), "\n"), "\n") { - if line == "" { - continue - } - b, m, ok := strings.Cut(line, "\t") - if !ok { - continue - } - if _, exists := groups[b]; !exists { - order = append(order, b) - } - groups[b] = append(groups[b], m) - } - out := make([]modelsEntry, len(order)) - for i, b := range order { - out[i] = modelsEntry{Backend: b, Models: groups[b]} - } - return out -} - -func parseIdx(data []byte) []idxEntry { - var entries []idxEntry - for line := range strings.SplitSeq(strings.TrimRight(string(data), "\n"), "\n") { - if line == "" { - continue - } - fields := strings.SplitN(line, "\t", 5) - if len(fields) != 5 { - continue - } - entries = append(entries, idxEntry{ - ID: fields[0], - State: fields[1], - CWD: fields[2], - Backend: fields[3], - Model: fields[4], - }) - } - return entries -} - -// parseContent detects the wire format and returns the appropriate Go value: -// - key=value lines → map[string]string -// - tab-separated records → [][]string -// - multiple newline-separated values → []string -// - single value → map[string]string{name: value} -func parseContent(data []byte, name string) any { - s := strings.TrimRight(string(data), "\n") - if s == "" { - return map[string]string{} - } - first, _, _ := strings.Cut(s, "\n") - if strings.Contains(first, "=") { - m := make(map[string]string) - for line := range strings.SplitSeq(s, "\n") { - if line == "" { - continue - } - k, v, _ := strings.Cut(line, "=") - m[k] = v - } - return m - } - if strings.Contains(first, "\t") { - var rows [][]string - for line := range strings.SplitSeq(s, "\n") { - if line != "" { - rows = append(rows, strings.Split(line, "\t")) - } - } - return rows - } - var lines []string - for line := range strings.SplitSeq(s, "\n") { - lines = append(lines, line) - } - if len(lines) == 1 { - return map[string]string{name: lines[0]} - } - return lines -} - -// serializeKV serializes a map to newline-separated key=value lines. -func serializeKV(m map[string]any) []byte { - var sb strings.Builder - for k, v := range m { - fmt.Fprintf(&sb, "%s=%v\n", k, v) - } - return []byte(sb.String()) -} - -func handleStat(w http.ResponseWriter, fsys *client.Fsys, p string) { - d, err := fsys.Stat(p) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return - } - setStatHeaders(w, d) - w.Header().Set("Content-Type", "application/json") -} - -func handleOpen(w http.ResponseWriter, r *http.Request) { - p := r.URL.Query().Get("path") - if p == "" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing path"}) - return - } - editor := os.Getenv("EDITOR") - if editor == "" { - editor = "xdg-open" - } - if err := exec.Command(editor, p).Start(); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]string{"opened": p}) -} - -func handlePut(w http.ResponseWriter, r *http.Request, fsys *client.Fsys, p string) { - body, err := io.ReadAll(r.Body) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - fid, err := fsys.Open(p, plan9.OWRITE|plan9.OTRUNC) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return - } - n, err := fid.Write(body) - fid.Close() - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]int{"bytes_written": n}) -} - -func handleWrite(w http.ResponseWriter, r *http.Request, fsys *client.Fsys, p string) { - var m map[string]any - if err := json.NewDecoder(r.Body).Decode(&m); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - // If the body has a single key matching the file name, write just the value. - // Otherwise serialize as key=value lines (e.g. for "new"). - var payload []byte - base := path.Base(p) - if len(m) == 1 { - if v, ok := m[base]; ok { - payload = []byte(fmt.Sprintf("%v", v)) - } - } - if payload == nil { - payload = serializeKV(m) - } - fid, err := fsys.Open(p, plan9.OWRITE) - if err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return - } - n, err := fid.Write(payload) - fid.Close() - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]int{"bytes_written": n}) -} - - -func handleRemove(w http.ResponseWriter, fsys *client.Fsys, p string) { - if err := fsys.Remove(p); err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) - return - } - w.WriteHeader(http.StatusNoContent) -} - -func handleWstat(w http.ResponseWriter, r *http.Request, fsys *client.Fsys, p string) { - var req struct { - Name string `json:"name"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - d := &plan9.Dir{Name: req.Name} - if err := fsys.Wstat(p, d); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - w.WriteHeader(http.StatusNoContent) -} diff --git a/ninep.go b/ninep.go new file mode 100644 index 0000000..2115a28 --- /dev/null +++ b/ninep.go @@ -0,0 +1,434 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path" + "strings" + + "9fans.net/go/plan9" + "9fans.net/go/plan9/client" +) + +func dial() (*client.Fsys, error) { + if *addr != "" { + n := *network + if n == "" { + n = "tcp" + } + return client.Mount(n, *addr) + } + return client.MountService(*service) +} + +type dirEntry struct { + Name string `json:"name"` + Size uint64 `json:"size"` + Mode uint32 `json:"mode"` + Mtime uint32 `json:"mtime"` + IsDir bool `json:"is_dir"` +} + +func dirToEntry(d *plan9.Dir) dirEntry { + return dirEntry{ + Name: d.Name, + Size: d.Length, + Mode: uint32(d.Mode), + Mtime: d.Mtime, + IsDir: d.Mode&plan9.DMDIR != 0, + } +} + +func setStatHeaders(w http.ResponseWriter, d *plan9.Dir) { + w.Header().Set("X-9p-Name", d.Name) + w.Header().Set("X-9p-Size", fmt.Sprintf("%d", d.Length)) + w.Header().Set("X-9p-Mode", fmt.Sprintf("%o", d.Mode)) + w.Header().Set("X-9p-Mtime", fmt.Sprintf("%d", d.Mtime)) + if d.Mode&plan9.DMDIR != 0 { + w.Header().Set("X-9p-Type", "dir") + } else { + w.Header().Set("X-9p-Type", "file") + } +} + +func ninepHandler(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/open" { + handleOpen(w, r) + return + } + + fsys, err := dial() + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": "9p connect: " + err.Error()}) + return + } + defer fsys.Close() + + p := strings.TrimPrefix(r.URL.Path, "/") + + // Internal paths not exposed via HTTP. + first, _, _ := strings.Cut(p, "/") + switch first { + case "p", "help", "tr", "u", "x", "tmp": + writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) + return + } + // Read-only paths — only GET and HEAD allowed. + switch first { + case "", "sk": + if r.Method != http.MethodGet && r.Method != http.MethodHead { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "read-only"}) + return + } + } + base := path.Base(p) + switch base { + case "systemprompt", "statewait": + writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) + return + case "new": + if r.Method == http.MethodPost { + handleWrite(w, r, fsys, p) + return + } + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + + switch r.Method { + case http.MethodGet: + handleRead(w, fsys, p) + case http.MethodHead: + handleStat(w, fsys, p) + case http.MethodPost: + if base == "chat" { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "chat is read-only"}) + return + } + handleWrite(w, r, fsys, p) + case http.MethodPut: + handlePut(w, r, fsys, p) + case http.MethodDelete: + handleRemove(w, fsys, p) + case http.MethodPatch: + handleWstat(w, r, fsys, p) + case http.MethodOptions: + handleNinepOptions(w, r, p) + default: + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + } +} + +var allowedMethods = "GET, HEAD, PUT, POST, DELETE, PATCH, OPTIONS" + +var methodDescriptions = map[string]string{ + "GET": "read file content or list directory entries", + "HEAD": "stat file (metadata in response headers)", + "PUT": "write raw body to file (truncate)", + "POST": "write to file or trigger action", + "DELETE": "remove file or session", + "PATCH": "rename via JSON {\"name\": \"newname\"}", + "OPTIONS": "describe available operations for this path", +} + +func handleNinepOptions(w http.ResponseWriter, _ *http.Request, p string) { + w.Header().Set("Allow", allowedMethods) + writeJSON(w, http.StatusOK, map[string]any{ + "path": "/" + p, + "methods": methodDescriptions, + }) +} + +func handleRead(w http.ResponseWriter, fsys *client.Fsys, p string) { + d, err := fsys.Stat(p) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + setStatHeaders(w, d) + + if d.Mode&plan9.DMDIR != 0 { + fid, err := fsys.Open(p, plan9.OREAD) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + defer fid.Close() + dirs, err := fid.Dirreadall() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if p == "s" { + var ids []string + for _, de := range dirs { + if de.Mode&plan9.DMDIR != 0 { + ids = append(ids, de.Name) + } + } + writeJSON(w, http.StatusOK, ids) + return + } + entries := make([]dirEntry, len(dirs)) + for i, de := range dirs { + entries[i] = dirToEntry(de) + } + writeJSON(w, http.StatusOK, entries) + return + } + + fid, err := fsys.Open(p, plan9.OREAD) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + defer fid.Close() + content, err := io.ReadAll(fid) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if p == "models" { + writeJSON(w, http.StatusOK, parseModels(content)) + return + } + if p == "s/idx" { + writeJSON(w, http.StatusOK, parseIdx(content)) + return + } + if path.Base(p) == "chat" { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Write(content) + return + } + if path.Base(p) == "cfg" { + m := parseContent(content, "cfg") + if mm, ok := m.(map[string]string); ok { + // Inject state from sibling file. + stateFile := path.Dir(p) + "/state" + if sf, err := fsys.Open(stateFile, plan9.OREAD); err == nil { + if sb, err := io.ReadAll(sf); err == nil { + mm["state"] = strings.TrimSpace(string(sb)) + } + sf.Close() + } + } + writeJSON(w, http.StatusOK, m) + return + } + writeJSON(w, http.StatusOK, parseContent(content, path.Base(p))) +} + +type idxEntry struct { + ID string `json:"id"` + State string `json:"state"` + CWD string `json:"cwd"` + Backend string `json:"backend"` + Model string `json:"model"` +} + +type modelsEntry struct { + Backend string `json:"backend"` + Models []string `json:"models"` +} + +func parseModels(data []byte) []modelsEntry { + groups := make(map[string][]string) + var order []string + for line := range strings.SplitSeq(strings.TrimRight(string(data), "\n"), "\n") { + if line == "" { + continue + } + b, m, ok := strings.Cut(line, "\t") + if !ok { + continue + } + if _, exists := groups[b]; !exists { + order = append(order, b) + } + groups[b] = append(groups[b], m) + } + out := make([]modelsEntry, len(order)) + for i, b := range order { + out[i] = modelsEntry{Backend: b, Models: groups[b]} + } + return out +} + +func parseIdx(data []byte) []idxEntry { + var entries []idxEntry + for line := range strings.SplitSeq(strings.TrimRight(string(data), "\n"), "\n") { + if line == "" { + continue + } + fields := strings.SplitN(line, "\t", 5) + if len(fields) != 5 { + continue + } + entries = append(entries, idxEntry{ + ID: fields[0], + State: fields[1], + CWD: fields[2], + Backend: fields[3], + Model: fields[4], + }) + } + return entries +} + +// parseContent detects the wire format and returns the appropriate Go value: +// - key=value lines → map[string]string +// - tab-separated records → [][]string +// - multiple newline-separated values → []string +// - single value → map[string]string{name: value} +func parseContent(data []byte, name string) any { + s := strings.TrimRight(string(data), "\n") + if s == "" { + return map[string]string{} + } + first, _, _ := strings.Cut(s, "\n") + if strings.Contains(first, "=") { + m := make(map[string]string) + for line := range strings.SplitSeq(s, "\n") { + if line == "" { + continue + } + k, v, _ := strings.Cut(line, "=") + m[k] = v + } + return m + } + if strings.Contains(first, "\t") { + var rows [][]string + for line := range strings.SplitSeq(s, "\n") { + if line != "" { + rows = append(rows, strings.Split(line, "\t")) + } + } + return rows + } + var lines []string + for line := range strings.SplitSeq(s, "\n") { + lines = append(lines, line) + } + if len(lines) == 1 { + return map[string]string{name: lines[0]} + } + return lines +} + +// serializeKV serializes a map to newline-separated key=value lines. +func serializeKV(m map[string]any) []byte { + var sb strings.Builder + for k, v := range m { + fmt.Fprintf(&sb, "%s=%v\n", k, v) + } + return []byte(sb.String()) +} + +func handleStat(w http.ResponseWriter, fsys *client.Fsys, p string) { + d, err := fsys.Stat(p) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + setStatHeaders(w, d) + w.Header().Set("Content-Type", "application/json") +} + +func handleOpen(w http.ResponseWriter, r *http.Request) { + p := r.URL.Query().Get("path") + if p == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing path"}) + return + } + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "xdg-open" + } + if err := exec.Command(editor, p).Start(); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"opened": p}) +} + +func handlePut(w http.ResponseWriter, r *http.Request, fsys *client.Fsys, p string) { + body, err := io.ReadAll(r.Body) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + fid, err := fsys.Open(p, plan9.OWRITE|plan9.OTRUNC) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + n, err := fid.Write(body) + fid.Close() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]int{"bytes_written": n}) +} + +func handleWrite(w http.ResponseWriter, r *http.Request, fsys *client.Fsys, p string) { + var m map[string]any + if err := json.NewDecoder(r.Body).Decode(&m); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + // If the body has a single key matching the file name, write just the value. + // Otherwise serialize as key=value lines (e.g. for "new"). + var payload []byte + base := path.Base(p) + if len(m) == 1 { + if v, ok := m[base]; ok { + payload = []byte(fmt.Sprintf("%v", v)) + } + } + if payload == nil { + payload = serializeKV(m) + } + fid, err := fsys.Open(p, plan9.OWRITE) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + n, err := fid.Write(payload) + fid.Close() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]int{"bytes_written": n}) +} + +func handleRemove(w http.ResponseWriter, fsys *client.Fsys, p string) { + if err := fsys.Remove(p); err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func handleWstat(w http.ResponseWriter, r *http.Request, fsys *client.Fsys, p string) { + var req struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + d := &plan9.Dir{Name: req.Name} + if err := fsys.Wstat(p, d); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/openapi.json b/openapi.json index 4232876..ddbb34e 100644 --- a/openapi.json +++ b/openapi.json @@ -2,110 +2,92 @@ "openapi": "3.0.3", "info": { "title": "ollie-httpgw", - "description": "HTTP gateway for the ollie 9P server.", - "version": "1.0.0" + "description": "HTTP gateway for ollie session management. Supports D-Bus (default) and 9P backend modes.", + "version": "2.0.0" }, + "servers": [ + { "url": "http://localhost:8011", "description": "Default local gateway" } + ], "paths": { - "/{path}": { - "parameters": [ - { - "name": "path", - "in": "path", - "required": true, - "description": "9P file or directory path (may contain slashes for nested paths)", - "schema": { "type": "string" }, - "example": "s/idx" - } - ], + "/sessions": { "get": { - "summary": "Read file or list directory", - "description": "Returns parsed file content as a JSON object or array, or a directory listing.", + "tags": ["dbus"], + "summary": "List sessions", + "description": "Returns all active sessions with their id, state, model, and agent.", "responses": { "200": { - "description": "File content or directory listing", + "description": "Session list", "content": { "application/json": { "schema": { - "oneOf": [ - { "title": "Key-value file", "type": "object", "additionalProperties": { "type": "string" } }, - { "title": "List file", "type": "array", "items": { "type": "string" } }, - { "title": "Session index", "type": "array", "items": { "$ref": "#/components/schemas/IdxEntry" } }, - { "title": "Directory", "type": "array", "items": { "$ref": "#/components/schemas/DirEntry" } } - ] + "type": "array", + "items": { "$ref": "#/components/schemas/SessionSummary" } } } } }, - "404": { "$ref": "#/components/responses/Error" }, "502": { "$ref": "#/components/responses/Error" } } }, - "head": { - "summary": "Stat file or directory", - "description": "Returns 9P metadata in response headers. No body.", - "responses": { - "200": { - "description": "Stat headers set", - "headers": { "$ref": "#/components/headers/StatHeaders" } - }, - "404": { "$ref": "#/components/responses/Error" } - } - }, "post": { - "summary": "Write to file or trigger action", - "description": "Writes a JSON object to a file, serialized as key=value lines. Used for session creation (POST /s/new), config updates (POST /s/{id}/cfg), and control actions (POST /s/{id}/ctl, /s/{id}/prompt, /s/{id}/fifo.in).", + "tags": ["dbus"], + "summary": "Create a new session", "requestBody": { - "required": true, + "required": false, "content": { "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } + "schema": { "$ref": "#/components/schemas/CreateSessionRequest" } } } }, "responses": { - "200": { - "description": "Bytes written", + "201": { + "description": "Session created", "content": { "application/json": { "schema": { "type": "object", - "properties": { - "bytes_written": { "type": "integer" } - }, - "required": ["bytes_written"] + "properties": { "id": { "type": "string" } }, + "required": ["id"] } } } }, "400": { "$ref": "#/components/responses/Error" }, - "404": { "$ref": "#/components/responses/Error" }, - "405": { "$ref": "#/components/responses/Error" }, - "500": { "$ref": "#/components/responses/Error" } + "502": { "$ref": "#/components/responses/Error" } + } + } + }, + "/sessions/{id}": { + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "get": { + "tags": ["dbus"], + "summary": "Get session config/detail", + "responses": { + "200": { + "description": "Session config as key-value object", + "content": { "application/json": { "schema": { "type": "object", "additionalProperties": { "type": "string" } } } } + } } }, "delete": { - "summary": "Remove file or session", - "description": "Removes the file or kills the session at the given path.", + "tags": ["dbus"], + "summary": "Kill session", "responses": { - "204": { "description": "Removed" }, + "204": { "description": "Session killed" }, "404": { "$ref": "#/components/responses/Error" } } }, "patch": { - "summary": "Rename file or session", - "description": "Renames the file or session via 9P wstat.", + "tags": ["dbus"], + "summary": "Rename session", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "properties": { - "name": { "type": "string", "description": "New name" } - }, + "properties": { "name": { "type": "string" } }, "required": ["name"] } } @@ -113,67 +95,261 @@ }, "responses": { "204": { "description": "Renamed" }, - "400": { "$ref": "#/components/responses/Error" }, - "500": { "$ref": "#/components/responses/Error" } + "404": { "$ref": "#/components/responses/Error" } } - }, - "options": { - "summary": "Describe available operations", - "description": "Returns the allowed HTTP methods and a description of each operation for this path.", - "responses": { - "200": { - "description": "Available methods", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/OptionsResponse" } + } + }, + "/sessions/{id}/submit": { + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "post": { + "tags": ["dbus"], + "summary": "Submit a prompt to the session", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { "prompt": { "type": "string" } }, + "required": ["prompt"] } } } + }, + "responses": { + "200": { "description": "Submitted", "content": { "application/json": { "schema": { "type": "object", "properties": { "submitted": { "type": "boolean" } } } } } }, + "404": { "$ref": "#/components/responses/Error" } + } + } + }, + "/sessions/{id}/interrupt": { + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "post": { + "tags": ["dbus"], + "summary": "Interrupt the running session", + "responses": { + "200": { "description": "Interrupted", "content": { "application/json": { "schema": { "type": "object", "properties": { "interrupted": { "type": "boolean" } } } } } }, + "404": { "$ref": "#/components/responses/Error" } + } + } + }, + "/sessions/{id}/state": { + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "get": { + "tags": ["dbus"], + "summary": "Get session state", + "responses": { + "200": { "description": "State", "content": { "application/json": { "schema": { "type": "object", "properties": { "state": { "type": "string" } } } } } } + } + } + }, + "/sessions/{id}/chat": { + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "offset", "in": "query", "required": false, "schema": { "type": "integer", "format": "int64", "default": 0 }, "description": "Byte offset to resume from" } + ], + "get": { + "tags": ["dbus"], + "summary": "Get chat transcript", + "description": "Returns chat text from the given offset. The X-Chat-Offset response header contains the new offset for subsequent requests.", + "responses": { + "200": { + "description": "Chat text", + "headers": { + "X-Chat-Offset": { "schema": { "type": "integer", "format": "int64" }, "description": "New byte offset for next poll" } + }, + "content": { "text/plain": { "schema": { "type": "string" } } } + } + } + } + }, + "/sessions/{id}/usage": { + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "get": { + "tags": ["dbus"], + "summary": "Get token usage", + "responses": { + "200": { "description": "Usage string", "content": { "application/json": { "schema": { "type": "object", "properties": { "usage": { "type": "string" } } } } } } + } + } + }, + "/sessions/{id}/cost": { + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "get": { + "tags": ["dbus"], + "summary": "Get session cost", + "responses": { + "200": { "description": "Cost string", "content": { "application/json": { "schema": { "type": "object", "properties": { "cost": { "type": "string" } } } } } } + } + } + }, + "/sessions/{id}/config": { + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "get": { + "tags": ["dbus"], + "summary": "Get session config", + "responses": { + "200": { "description": "Config as key-value", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": { "type": "string" } } } } } + } + }, + "post": { + "tags": ["dbus"], + "summary": "Set a config value", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "key": { "type": "string" }, + "value": { "type": "string" } + }, + "required": ["key", "value"] + } + } + } + }, + "responses": { + "200": { "description": "Success", "content": { "application/json": { "schema": { "type": "object", "properties": { "success": { "type": "boolean" } } } } } }, + "400": { "$ref": "#/components/responses/Error" } + } + } + }, + "/sessions/{id}/context": { + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "get": { + "tags": ["dbus"], + "summary": "Get message context (JSONL)", + "responses": { + "200": { "description": "JSONL messages", "content": { "application/x-ndjson": { "schema": { "type": "string" } } } } + } + } + }, + "/sessions/{id}/models": { + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "get": { + "tags": ["dbus"], + "summary": "List models for session's backend", + "responses": { + "200": { "description": "Model list", "content": { "application/json": { "schema": { "type": "array", "items": { "type": "string" } } } } } + } + } + }, + "/sessions/{id}/peers": { + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "get": { + "tags": ["dbus"], + "summary": "List peers", + "responses": { + "200": { "description": "Peer IDs", "content": { "application/json": { "schema": { "type": "array", "items": { "type": "string" } } } } } + } + }, + "post": { + "tags": ["dbus"], + "summary": "Add a peer", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { "peer_id": { "type": "string" } }, + "required": ["peer_id"] + } + } + } + }, + "responses": { + "201": { "description": "Peer added" }, + "400": { "$ref": "#/components/responses/Error" } + } + } + }, + "/sessions/{id}/peers/{peer_id}": { + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "peer_id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "delete": { + "tags": ["dbus"], + "summary": "Remove a peer", + "responses": { + "204": { "description": "Peer removed" }, + "404": { "$ref": "#/components/responses/Error" } + } + } + }, + "/sessions/{id}/peers/{peer_id}/submit": { + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "peer_id", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "post": { + "tags": ["dbus"], + "summary": "Submit prompt to a peer session", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { "prompt": { "type": "string" } }, + "required": ["prompt"] + } + } + } + }, + "responses": { + "200": { "description": "Submitted" }, + "404": { "$ref": "#/components/responses/Error" } + } + } + }, + "/backends": { + "get": { + "tags": ["dbus"], + "summary": "List available backends", + "responses": { + "200": { "description": "Backend names", "content": { "application/json": { "schema": { "type": "array", "items": { "type": "string" } } } } } + } + } + }, + "/agents": { + "get": { + "tags": ["dbus"], + "summary": "List available agents", + "responses": { + "200": { "description": "Agent names", "content": { "application/json": { "schema": { "type": "array", "items": { "type": "string" } } } } } } } } }, "components": { "schemas": { - "IdxEntry": { + "SessionSummary": { "type": "object", "properties": { - "id": { "type": "string" }, - "state": { "type": "string" }, - "cwd": { "type": "string" }, - "backend": { "type": "string" }, - "model": { "type": "string" } + "id": { "type": "string" }, + "state": { "type": "string" }, + "model": { "type": "string" }, + "agent": { "type": "string" } }, - "required": ["id", "state", "cwd", "backend", "model"] + "required": ["id", "state", "model", "agent"] }, - "DirEntry": { + "CreateSessionRequest": { "type": "object", "properties": { - "name": { "type": "string" }, - "size": { "type": "integer", "format": "uint64" }, - "mode": { "type": "integer", "format": "uint32" }, - "mtime": { "type": "integer", "format": "uint32" }, - "is_dir": { "type": "boolean" } - }, - "required": ["name", "size", "mode", "mtime", "is_dir"] + "cwd": { "type": "string", "description": "Working directory (defaults to $HOME)" }, + "backend": { "type": "string", "description": "Backend name (e.g. ollama, anthropic)" }, + "model": { "type": "string", "description": "Model name" }, + "agent": { "type": "string", "description": "Agent name (default: 'default')" } + } }, "Error": { "type": "object", - "properties": { - "error": { "type": "string" } - }, + "properties": { "error": { "type": "string" } }, "required": ["error"] - }, - "OptionsResponse": { - "type": "object", - "properties": { - "path": { "type": "string" }, - "methods": { - "type": "object", - "additionalProperties": { "type": "string" } - } - }, - "required": ["path", "methods"] } }, "responses": { @@ -185,16 +361,6 @@ } } } - }, - "headers": { - "StatHeaders": { - "description": "9P stat fields returned as response headers", - "X-9p-Name": { "schema": { "type": "string" } }, - "X-9p-Size": { "schema": { "type": "integer" } }, - "X-9p-Mode": { "schema": { "type": "string" } }, - "X-9p-Mtime": { "schema": { "type": "integer" } }, - "X-9p-Type": { "schema": { "type": "string", "enum": ["file", "dir"] } } - } } } }