helmd v2: Go-server som styr agent i tmux — REST+SSE, frågedetektor, fildelning, ntfy, CI-release
- tmux som sanningskälla (agentoberoende, överlever omstart via adoption) - API: sessions, prompt, question/answer, keys, mode, config, shares, notify, events - frågedetektor testad mot riktiga agy 1.1.9-dumpar + mock - integrationstest: 26 tester i isolerad debian-container (scripts/run-integration.sh) - e2e-verifierad mot riktig agy (trust-fråga -> svar -> prompt) - .gitea/workflows/helmd-release.yaml -> rullande helmd-latest (x64+arm64) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
361
helmd/api.go
Normal file
361
helmd/api.go
Normal file
@@ -0,0 +1,361 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
cfg *Config
|
||||
mgr *Manager
|
||||
shares *ShareStore
|
||||
ntfy *Ntfy
|
||||
}
|
||||
|
||||
func (a *API) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tok := r.Header.Get("X-Api-Token")
|
||||
if tok == "" {
|
||||
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||||
tok = strings.TrimPrefix(h, "Bearer ")
|
||||
}
|
||||
}
|
||||
if tok == "" {
|
||||
tok = r.URL.Query().Get("token") // SSE/EventSource + <img> links
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(tok), []byte(a.cfg.Token)) != 1 {
|
||||
jsonErr(w, http.StatusUnauthorized, "ogiltig eller saknad token")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonOut(w http.ResponseWriter, code int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func jsonErr(w http.ResponseWriter, code int, msg string) {
|
||||
jsonOut(w, code, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func readJSON(r *http.Request, v interface{}) error {
|
||||
defer r.Body.Close()
|
||||
return json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(v)
|
||||
}
|
||||
|
||||
func (a *API) routes() *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /api/health", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOut(w, 200, map[string]interface{}{
|
||||
"ok": true, "version": version, "sessions": len(a.mgr.List()),
|
||||
})
|
||||
}))
|
||||
|
||||
mux.HandleFunc("GET /api/sessions", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOut(w, 200, a.mgr.List())
|
||||
}))
|
||||
|
||||
mux.HandleFunc("POST /api/sessions", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Cmd string `json:"cmd"`
|
||||
Cwd string `json:"cwd"`
|
||||
Adopt bool `json:"adopt"`
|
||||
Tmux string `json:"tmux"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
var (
|
||||
s *SessionState
|
||||
err error
|
||||
)
|
||||
if req.Adopt {
|
||||
s, err = a.mgr.Adopt(req.Name, req.Tmux)
|
||||
} else {
|
||||
s, err = a.mgr.Create(req.Name, req.Cmd, req.Cwd)
|
||||
}
|
||||
if err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOut(w, 201, s)
|
||||
}))
|
||||
|
||||
mux.HandleFunc("GET /api/sessions/{name}", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
s, ok := a.mgr.Get(r.PathValue("name"))
|
||||
if !ok {
|
||||
jsonErr(w, 404, "ingen sådan session")
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, s)
|
||||
}))
|
||||
|
||||
mux.HandleFunc("DELETE /api/sessions/{name}", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := a.mgr.Kill(r.PathValue("name")); err != nil {
|
||||
jsonErr(w, 404, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, map[string]bool{"killed": true})
|
||||
}))
|
||||
|
||||
mux.HandleFunc("GET /api/sessions/{name}/screen", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
ansi := r.URL.Query().Get("ansi") == "1"
|
||||
history, _ := strconv.Atoi(r.URL.Query().Get("history"))
|
||||
screen, err := a.mgr.Screen(r.PathValue("name"), ansi, history)
|
||||
if err != nil {
|
||||
jsonErr(w, 404, err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
io.WriteString(w, screen)
|
||||
}))
|
||||
|
||||
mux.HandleFunc("POST /api/sessions/{name}/prompt", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Text string `json:"text"`
|
||||
Submit *bool `json:"submit"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil || req.Text == "" {
|
||||
jsonErr(w, 400, "JSON-kropp med \"text\" krävs")
|
||||
return
|
||||
}
|
||||
submit := req.Submit == nil || *req.Submit
|
||||
if err := a.mgr.Prompt(r.PathValue("name"), req.Text, submit); err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, map[string]bool{"sent": true})
|
||||
}))
|
||||
|
||||
mux.HandleFunc("GET /api/sessions/{name}/question", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
s, ok := a.mgr.Get(r.PathValue("name"))
|
||||
if !ok {
|
||||
jsonErr(w, 404, "ingen sådan session")
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, map[string]interface{}{"question": s.Question})
|
||||
}))
|
||||
|
||||
mux.HandleFunc("POST /api/sessions/{name}/answer", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Option int `json:"option"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if err := a.mgr.Answer(r.PathValue("name"), req.Option); err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, map[string]bool{"answered": true})
|
||||
}))
|
||||
|
||||
mux.HandleFunc("POST /api/sessions/{name}/keys", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Keys []string `json:"keys"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil || len(req.Keys) == 0 {
|
||||
jsonErr(w, 400, "JSON-kropp med \"keys\": [\"Enter\", ...] krävs")
|
||||
return
|
||||
}
|
||||
if err := a.mgr.Keys(r.PathValue("name"), req.Keys); err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, map[string]bool{"sent": true})
|
||||
}))
|
||||
|
||||
// Mode: "cycle" sends the agent's mode-switch key (Shift+Tab in
|
||||
// agy/Claude Code). Arbitrary key sequences via /keys.
|
||||
mux.HandleFunc("POST /api/sessions/{name}/mode", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if req.Action != "cycle" {
|
||||
jsonErr(w, 400, "stödd action: \"cycle\"")
|
||||
return
|
||||
}
|
||||
if err := a.mgr.Keys(r.PathValue("name"), []string{"BTab"}); err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, map[string]bool{"sent": true})
|
||||
}))
|
||||
|
||||
mux.HandleFunc("GET /api/config", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
c := *a.cfg
|
||||
c.Token = "(dold — se konfigfilen)"
|
||||
jsonOut(w, 200, c)
|
||||
}))
|
||||
|
||||
mux.HandleFunc("PUT /api/config", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
AgentCmd *string `json:"agent_cmd"`
|
||||
AgentArgs *[]string `json:"agent_args"`
|
||||
Workdir *string `json:"workdir"`
|
||||
PollMs *int `json:"poll_ms"`
|
||||
Ntfy *NtfyConfig `json:"ntfy"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if req.AgentCmd != nil {
|
||||
a.cfg.AgentCmd = *req.AgentCmd
|
||||
}
|
||||
if req.AgentArgs != nil {
|
||||
a.cfg.AgentArgs = *req.AgentArgs
|
||||
}
|
||||
if req.Workdir != nil {
|
||||
a.cfg.Workdir = *req.Workdir
|
||||
}
|
||||
if req.PollMs != nil && *req.PollMs >= 100 {
|
||||
a.cfg.PollMs = *req.PollMs
|
||||
}
|
||||
if req.Ntfy != nil {
|
||||
a.cfg.Ntfy = *req.Ntfy
|
||||
}
|
||||
if err := a.cfg.Save(); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, map[string]bool{"saved": true})
|
||||
}))
|
||||
|
||||
mux.HandleFunc("GET /api/shares", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOut(w, 200, a.shares.List())
|
||||
}))
|
||||
|
||||
mux.HandleFunc("POST /api/shares", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(64 << 20); err != nil {
|
||||
jsonErr(w, 400, "multipart-form med fältet \"file\" krävs: "+err.Error())
|
||||
return
|
||||
}
|
||||
f, hdr, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
jsonErr(w, 400, "fältet \"file\" saknas")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
sh, err := a.shares.Add(hdr.Filename, r.FormValue("note"), r.FormValue("session"), f)
|
||||
if err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
a.mgr.emit(Event{Type: "share", Data: sh})
|
||||
jsonOut(w, 201, sh)
|
||||
}))
|
||||
|
||||
mux.HandleFunc("GET /api/shares/{id}", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
sh, err := a.shares.Get(r.PathValue("id"))
|
||||
if err != nil {
|
||||
jsonErr(w, 404, "ingen sådan delning")
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, sh)
|
||||
}))
|
||||
|
||||
mux.HandleFunc("GET /api/shares/{id}/raw", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
sh, err := a.shares.Get(r.PathValue("id"))
|
||||
if err != nil {
|
||||
jsonErr(w, 404, "ingen sådan delning")
|
||||
return
|
||||
}
|
||||
if sh.Mime != "" {
|
||||
w.Header().Set("Content-Type", sh.Mime)
|
||||
}
|
||||
w.Header().Set("Content-Security-Policy", "sandbox allow-scripts") // shared HTML must not reach the API with our token
|
||||
http.ServeFile(w, r, a.shares.Path(sh))
|
||||
}))
|
||||
|
||||
mux.HandleFunc("DELETE /api/shares/{id}", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := a.shares.Delete(r.PathValue("id")); err != nil {
|
||||
jsonErr(w, 404, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, map[string]bool{"deleted": true})
|
||||
}))
|
||||
|
||||
mux.HandleFunc("POST /api/notify", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Topic string `json:"topic"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Priority string `json:"priority"`
|
||||
}
|
||||
if err := readJSON(r, &req); err != nil || req.Message == "" {
|
||||
jsonErr(w, 400, "JSON-kropp med \"message\" krävs")
|
||||
return
|
||||
}
|
||||
if req.Topic == "" {
|
||||
req.Topic = a.cfg.Ntfy.Topic
|
||||
}
|
||||
if !a.cfg.topicAllowed(req.Topic) {
|
||||
jsonErr(w, 403, "topic inte i allowed_topics")
|
||||
return
|
||||
}
|
||||
if err := a.ntfy.Publish(req.Topic, req.Title, req.Message, req.Priority); err != nil {
|
||||
jsonErr(w, 502, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOut(w, 200, map[string]bool{"published": true})
|
||||
}))
|
||||
|
||||
mux.HandleFunc("GET /api/events", a.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||
fl, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
jsonErr(w, 500, "streaming stöds inte")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
ch := a.mgr.Subscribe()
|
||||
defer a.mgr.Unsubscribe(ch)
|
||||
fmt.Fprintf(w, "event: hello\ndata: {\"version\":%q}\n\n", version)
|
||||
fl.Flush()
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case ev := <-ch:
|
||||
data, _ := json.Marshal(ev)
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, data)
|
||||
fl.Flush()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
func serve(cfg *Config) error {
|
||||
if _, err := exec_LookPath("tmux"); err != nil {
|
||||
return fmt.Errorf("tmux hittas inte i PATH — helmd kräver tmux")
|
||||
}
|
||||
ntfy := &Ntfy{cfg: &cfg.Ntfy}
|
||||
mgr := NewManager(cfg, ntfy)
|
||||
shares, err := NewShareStore(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
api := &API{cfg: cfg, mgr: mgr, shares: shares, ntfy: ntfy}
|
||||
log.Printf("helmd %s lyssnar på http://%s (token i %s)", version, cfg.Listen, cfg.path)
|
||||
return http.ListenAndServe(cfg.Listen, api.routes())
|
||||
}
|
||||
Reference in New Issue
Block a user