package main import ( "cmp" "context" "embed" "encoding/binary" "encoding/json" "errors" "flag" "fmt" "html/template" "io" "io/fs" "log" "net" "net/http" "net/url" "os" "os/signal" "strings" "time" "github.com/coder/websocket" "github.com/kami/maven/internal/audio" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/voice" "github.com/kami/maven/internal/webauthn" ) // presenceSignals — the only fact keys /api/signal may write. mavweb is a // network-facing surface inside wg; an allowlist keeps a compromised caller // boxed to forging weak presence signals (reachability, multi-source, never // truth) — it can't write arbitrary facts. ponytail: floor auth (wg-only); a // per-signal token belongs here if the tunnel ever hosts untrusted devices. var presenceSignals = map[string]string{ "desk_active": "infer:hyprland", "page_heartbeat": "infer:heartbeat", "wg_handshake": "infer:wg", } //go:embed static/* var staticFiles embed.FS //go:embed dash.html var dashHTML string //go:embed history.html var historyHTML string //go:embed trace.html var traceHTML string //go:embed notifications.html var notificationsHTML string //go:embed reminders.html var remindersHTML string //go:embed voice.html var voiceHTML string //go:embed ecosystem.html var ecosystemHTML string // ── Ethos Workstation Shell ── // // Two template pieces that wrap every page: // {{template "shellTop" ""}} ← opens , topbar, sidebar, content // {{template "shellBottom"}} ← closes content, inspector, // // The page-key argument highlights the active sidebar link and sets breadcrumbs. // sidebarSections maps sidebar section → page entries {label, url, icon} var sidebarSections = []struct { Label string Pages []struct{ Label, URL, Key string } }{ { Label: "Workspace", Pages: []struct{ Label, URL, Key string }{ {Label: "Dashboard", URL: "/dash", Key: "dash"}, }, }, { Label: "Infrastructure", Pages: []struct{ Label, URL, Key string }{ {Label: "History", URL: "/history", Key: "history"}, }, }, { Label: "Automation", Pages: []struct{ Label, URL, Key string }{ {Label: "Rule Trace", URL: "/trace", Key: "trace"}, {Label: "Notifications", URL: "/notifications", Key: "notifications"}, {Label: "Reminders", URL: "/reminders", Key: "reminders"}, {Label: "Routines", URL: "/routines", Key: "routines"}, }, }, { Label: "Ecosystem", Pages: []struct{ Label, URL, Key string }{ {Label: "Siblings", URL: "/ecosystem", Key: "ecosystem"}, }, }, { Label: "AI", Pages: []struct{ Label, URL, Key string }{ {Label: "Chat", URL: "/chat", Key: "chat"}, {Label: "Voice", URL: "/", Key: "voice"}, }, }, { Label: "Settings", Pages: []struct{ Label, URL, Key string }{ {Label: "Tools", URL: "/tools", Key: "tools"}, {Label: "Passkey", URL: "/auth/passkey", Key: "passkey"}, }, }, } func sidebarActive(url, key string, activeKey string) string { if key == activeKey { return `class="active"` } return "" } // sidebarHTML renders the sidebar navigation given the active page key. func sidebarHTML(active string) template.HTML { var b strings.Builder for _, sec := range sidebarSections { b.WriteString(``) } return template.HTML(b.String()) } // pageIcon returns an ethos-icons.svg reference for the given page. func pageIcon(key string) string { switch key { case "dash": return `` case "history": return `` case "trace": return `` case "notifications": return `` case "reminders": return `` case "routines": return `` case "chat": return `` case "voice": return `` case "ecosystem": return `` case "tools": return `` case "passkey": return `` default: return `` } } // pageTitle returns the human-readable page title for the given key. func pageTitle(key string) string { switch key { case "dash": return "Dashboard" case "history": return "History" case "trace": return "Rule Trace" case "notifications": return "Notifications" case "reminders": return "Reminders" case "routines": return "Routines" case "chat": return "Chat" case "voice": return "Voice" case "ecosystem": return "Ecosystem" case "tools": return "Tools" case "passkey": return "Passkey" default: return key } } // shellTopHTML opens the shell and renders the top bar + sidebar. // Usage: {{template "shellTop" ""}} const shellTopHTML = `{{define "shellTop"}} maven · {{pageTitle .}}
Search Ctrl+/
{{end}}` // shellBottomHTML closes the content area, inspector, and shell. // Usage: {{template "shellBottom"}} const shellBottomHTML = `{{define "shellBottom"}}
{{end}}` // shellFuncs returns the FuncMap shared by every server-rendered page template. func shellFuncs() template.FuncMap { return template.FuncMap{ "pageTitle": pageTitle, "sidebarHTML": sidebarHTML, "ago": func(t time.Time) string { if t.IsZero() { return "never" } return time.Since(t).Round(time.Second).String() + " ago" }, "connected": func() bool { return true }, // if page renders, core was available } } // dashTmpl — the monitoring read surface, server-rendered from dash.html; // a small fetch loop refreshes the tables in place. html/template escapes the // user text in facts/nudges. Read-only: browses the append-only store via // CoreAPI, never writes — the store IS the audit trail, this just shows it. var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shellTopHTML + dashHTML + shellBottomHTML)) // ecosystemTmpl — read-only view of the Nexus/Praxis/Hexis siblings, whose only // human surface is here (they ship no web UI of their own). var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).Parse(shellTopHTML + ecosystemHTML + shellBottomHTML)) func noCache(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") h.ServeHTTP(w, r) }) } func main() { addr := flag.String("addr", ":9200", "HTTP listen address") voiceAddr := flag.String("voice", "127.0.0.1:9100", "voice server TCP addr (host:port)") // ntfyWS: the ntfy WebSocket subscribe URL the PWA connects to for in-app // nudge delivery, e.g. wss://ntfy.kvmx.ru/maven/ws?auth=. The // client subscribes directly (lowest overhead — mavweb isn't in the path); // we only serve it the URL so the deny-all auth token stays deployment // config, never baked into the static JS. Empty ⇒ /api/ntfy returns 204 and // the PWA skips subscription (voice-only, as before). ntfyWS := flag.String("ntfy", "", "ntfy WebSocket subscribe URL served to the PWA (e.g. wss://host/topic/ws?auth=...)") // coreSock: mavend's IPC socket. When set, /api/signal writes presence // facts through CoreAPI (page heartbeat from the PWA, desk_active from a PC // script). Empty ⇒ /api/signal returns 503 and presence stays unfed. coreSock := flag.String("core", "", "mavend IPC socket path for presence-signal ingest (empty = disabled)") pkOrigin := flag.String("webauthn-origin", "", "WebAuthn origin URL (e.g. https://maven.kvmx.ru)") pkRPID := flag.String("webauthn-rpid", "", "WebAuthn RP ID (e.g. maven.kvmx.ru)") requireStepUp := flag.Bool("require-stepup", false, "fail closed on step-up-gated actions (/tools POST, /api/revert) when WebAuthn step-up cannot be asserted; default false preserves the historical fail-open behaviour") pkFile := flag.String("passkey-file", "./passkeys.json", "path to WebAuthn credential store (JSON)") nexusURL := flag.String("nexus", "", "Nexus base URL for the /ecosystem panel (empty = not configured)") praxisURL := flag.String("praxis", "", "Praxis base URL for the /ecosystem panel (empty = not configured)") hexisURL := flag.String("hexis", "", "Hexis base URL for the /ecosystem panel (empty = not configured)") flag.Parse() var core ipc.CoreAPI if *coreSock != "" { c, err := ipc.DialWait(*coreSock, 60*time.Second) if err != nil { log.Fatalf("dial core %s: %v", *coreSock, err) } defer c.Close() core = c } mux := http.NewServeMux() sub, err := fs.Sub(staticFiles, "static") if err != nil { log.Fatalf("static fs: %v", err) } staticHandler := noCache(http.FileServer(http.FS(sub))) mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { staticHandler.ServeHTTP(w, r) return } handleVoice(w, r) })) mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { handleWS(w, r, *voiceAddr) }) mux.HandleFunc("/api/ptt", func(w http.ResponseWriter, r *http.Request) { handlePTT(w, r, *voiceAddr) }) mux.HandleFunc("/api/ping", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("pong")) }) mux.HandleFunc("/api/ntfy", func(w http.ResponseWriter, r *http.Request) { if *ntfyWS == "" { w.WriteHeader(http.StatusNoContent) // not configured → PWA skips return } w.Header().Set("Content-Type", "text/plain") w.Write([]byte(*ntfyWS)) }) mux.HandleFunc("/api/signal", func(w http.ResponseWriter, r *http.Request) { handleSignal(w, r, core) }) mux.HandleFunc("/dash", func(w http.ResponseWriter, r *http.Request) { handleDash(w, r, core) }) mux.HandleFunc("/history", func(w http.ResponseWriter, r *http.Request) { handleHistory(w, r, core) }) mux.HandleFunc("/trace", func(w http.ResponseWriter, r *http.Request) { handleTrace(w, r, core) }) mux.HandleFunc("/notifications", func(w http.ResponseWriter, r *http.Request) { handleNotifications(w, r, core) }) mux.HandleFunc("/reminders", func(w http.ResponseWriter, r *http.Request) { handleReminders(w, r, core) }) mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) { handleRoutines(w, r, core) }) ecoURLsCfg := ecoURLs{nexus: *nexusURL, praxis: *praxisURL, hexis: *hexisURL} mux.HandleFunc("/ecosystem", func(w http.ResponseWriter, r *http.Request) { handleEcosystem(w, r, ecoURLsCfg) }) // ----- passkey (WebAuthn) endpoints ----- // Wired when both -core and a configured origin are present. The origin // must match the browser's view of mavweb (e.g. https://maven.kvmx.ru). // Passkey registration + assertion are the step-up mechanism for // AuthStepUp actions (tool enable). Without -webauthn-origin, these // endpoints return 503 and step-up is unavailable (FloorSession). // stepUpSession stays nil unless the passkey endpoints are wired — it can // only ever be asserted via AssertFinish, so gating POST /tools on it // without those endpoints would make tool enable/disable permanently 403. // Without WebAuthn configured, /tools falls back to the transport-level // auth it sits behind (wg+nginx+auth), same as before step-up existed. var stepUpSession *webauthn.PasskeySession if *pkOrigin != "" && *pkRPID != "" && core != nil { stepUpSession = webauthn.NewPasskeySession(5 * time.Minute) pk, err := newPasskeyHandle(webauthn.Config{ Origin: *pkOrigin, RPID: *pkRPID, RPName: "maven", }, core, *pkFile, stepUpSession) if err != nil { log.Fatalf("passkey store: %v", err) } mux.HandleFunc("/auth/passkey", pk.Page) mux.HandleFunc("/auth/webauthn/register/begin", pk.RegisterBegin) mux.HandleFunc("/auth/webauthn/register/finish", pk.RegisterFinish) mux.HandleFunc("/auth/webauthn/assert/begin", pk.AssertBegin) mux.HandleFunc("/auth/webauthn/assert/finish", pk.AssertFinish) } if stepUpSession == nil { if *requireStepUp { log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set: POST /tools (tool enable/disable/dismiss — defines and executes arbitrary argv) and POST /api/revert will be DENIED (403). Set -webauthn-origin and -webauthn-rpid to enable passkey step-up.") } else { log.Printf("SECURITY WARNING: step-up verification is DISABLED because -webauthn-origin/-webauthn-rpid are unset. UNGUARDED SURFACES: POST /tools (defines arbitrary argv via name+cmd, which internal/tool then EXECUTES) and POST /api/revert (voids the latest fact for a key). These are protected only by whatever transport-level auth sits in front of mavweb (wg+nginx+auth) — do NOT expose -addr on a public interface. Set -webauthn-origin and -webauthn-rpid to require passkey step-up, or pass -require-stepup to fail closed instead.") } } // /tools — the authed enable surface. maven proposes acts she can't run; // this page is where a human reviews and enables them (proposed→enabled). // Enabling is the boundary-moving act (DESIGN.md § Tool registration — // drafting is suggest, enabling is act), so it lives ONLY here, // behind wg+nginx+auth — never the voice/chat path. mux.HandleFunc("/tools", func(w http.ResponseWriter, r *http.Request) { handleTools(w, r, core, stepUpSession, *requireStepUp) }) // /api/revert voids the latest fact for a key — a store mutation, so it // sits behind the same passkey step-up as tool enable (nil session ⇒ // WebAuthn unconfigured ⇒ transport-level auth only, same as /tools). mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) { handleChatPage(w, r, core) }) mux.HandleFunc("/api/chat", func(w http.ResponseWriter, r *http.Request) { handleChatAPI(w, r, core) }) mux.HandleFunc("/api/revert", func(w http.ResponseWriter, r *http.Request) { handleRevert(w, r, core, stepUpSession, *requireStepUp) }) srv := &http.Server{Addr: *addr, Handler: mux} go func() { sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt) <-sig log.Println("shutting down...") srv.Close() }() log.Printf("mavweb listening on %s, voice → %s", *addr, *voiceAddr) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatal(err) } } func handleWS(w http.ResponseWriter, r *http.Request, voiceAddr string) { conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ OriginPatterns: []string{"*"}, }) if err != nil { log.Printf("ws accept: %v", err) return } defer conn.Close(websocket.StatusNormalClosure, "bye") ctx := r.Context() var d net.Dialer tc, err := d.DialContext(ctx, "tcp", voiceAddr) if err != nil { log.Printf("dial voice: %v", err) writeWSErr(conn, ctx, "voice unavailable") return } defer tc.Close() for { _, msg, err := conn.Read(ctx) if err != nil { log.Printf("ws read: %v", err) return } if len(msg) < 4 { log.Printf("ws msg too short (%d bytes)", len(msg)) continue } log.Printf("ws got %d bytes from client", len(msg)) pcm := audio.Audio{Format: audio.PCM16kMono, Bytes: msg} req := voice.Request{ ID: uint64(time.Now().UnixNano()), Method: voice.MethodPushToTalk, Params: mustMarshal(voice.PushToTalkReq{ Audio: pcm, Lang: "mixed", Surface: voice.SurfacePCClient, }), } if err := writeFrame(tc, &req); err != nil { log.Printf("write voice req: %v", err) return } // Read frames until we get the matching Response (handling any interleaved Pushes) for { resp, push, err := readOneFrame(tc) if err != nil { log.Printf("read voice: %v", err) return } if push != nil { data, _ := json.Marshal(push) conn.Write(ctx, websocket.MessageText, data) continue } if resp.Error != nil { writeWSErr(conn, ctx, resp.Error.Message) break } var pttResp voice.PushToTalkResp if err := json.Unmarshal(resp.Result, &pttResp); err != nil { log.Printf("unmarshal resp: %v", err) break } if pttResp.ReplyText != "" { conn.Write(ctx, websocket.MessageText, []byte(pttResp.ReplyText)) } if len(pttResp.ReplyAudio.Bytes) > 0 { conn.Write(ctx, websocket.MessageBinary, pttResp.ReplyAudio.Bytes) } break } } } // handleSignal ingests one presence signal and writes a fresh fact through // CoreAPI. The fact's timestamp (now) is all the presence scorer reads; value // is a marker. Only allowlisted keys are accepted (see presenceSignals). func handleSignal(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) return } if core == nil { http.Error(w, "presence ingest disabled (no -core)", http.StatusServiceUnavailable) return } key := r.URL.Query().Get("key") source, ok := presenceSignals[key] if !ok { http.Error(w, "unknown signal key", http.StatusBadRequest) return } // kind=env: an observation about the device/surface, NOT a self-fact — a // passive signal never writes truth about you (spec), it only feeds // presence. confidence 1.0: the reading ("input happened") is certain; // presence applies its own per-signal weight/decay on top. if _, err := core.WriteFact(r.Context(), ipc.WriteFactReq{ Ts: time.Now(), Kind: "env", Key: key, Value: `"active"`, Source: source, Confidence: 1.0, }); err != nil { log.Printf("signal %s: %v", key, err) http.Error(w, "write failed", http.StatusBadGateway) return } w.WriteHeader(http.StatusNoContent) } func handleDash(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { if core == nil { http.Error(w, "dash disabled (no -core)", http.StatusServiceUnavailable) return } ctx := r.Context() pres, err1 := core.Presence(ctx) facts, err2 := core.RecentFacts(ctx, 50) nudges, err3 := core.RecentNudges(ctx, 50) notes, err4 := core.RecentNotes(ctx, 50) if err := cmp.Or(err1, err2, err3, err4); err != nil { log.Printf("dash: %v", err) http.Error(w, "core read failed", http.StatusBadGateway) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := dashTmpl.Execute(w, struct { Presence ipc.Presence Facts []ipc.Fact Nudges []ipc.Nudge Notes []ipc.Note }{pres, facts, nudges, notes}); err != nil { log.Printf("dash render: %v", err) } } // toolsTmpl — the enable surface. Server-rendered, no JS: a plain HTML form // POSTs back to /tools to enable a proposal. html/template escapes tool names + // utterances (they came from voice STT — untrusted text). var toolsTmpl = template.Must(template.New("tools").Funcs(func() template.FuncMap { m := shellFuncs() m["join"] = strings.Join return m }()).Parse(shellTopHTML + toolsHTML + shellBottomHTML)) const toolsHTML = `{{template "shellTop" "tools"}}

Tools

enabling requires step-up — assert a passkey first.

{{if .Msg}}
{{.Msg}}
{{end}}

proposed {{len .Proposed}}

{{if .Proposed}}

maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable.

{{range .Proposed}}{{end}}
namescopefrom utteranceenable as
{{.Name}}{{.Scope}}{{.Utterance}}
{{else}}
no proposed tools
maven will propose tools here when she needs help running an action
{{end}}

enabled {{len .Enabled}}

{{if .Enabled}}
{{range .Enabled}}{{end}}
namescopecommand
{{.Name}}{{.Scope}}{{join .Cmd " "}} {{if .Destructive}}destructive{{end}}
{{else}}
no tools enabled
enable proposed tools above, or ask maven to configure one
{{end}}
{{template "shellBottom"}}` // routinesHTML — proposed routine review surface. Lists detected patterns // awaiting human confirmation, with accept (→ reminder) and dismiss buttons. const routinesHTML = `{{template "shellTop" "routines"}}

Routines

{{if .Msg}}
{{.Msg}}
{{end}}

proposed {{len .Proposed}}

{{if .Proposed}}
{{range .Proposed}}{{end}}
actionobjectevery
{{.Action}}{{.Object}}{{.IntervalDays}} days
{{else}}
no proposed routines
maven will propose routines here when she detects a recurring pattern
{{end}}
{{template "shellBottom"}}` var historyTmpl = template.Must(template.New("history").Funcs(shellFuncs()).Parse(shellTopHTML + historyHTML + shellBottomHTML)) var notificationsTmpl = template.Must(template.New("notifications").Funcs(shellFuncs()).Parse(shellTopHTML + notificationsHTML + shellBottomHTML)) var remindersTmpl = template.Must(template.New("reminders").Funcs(shellFuncs()).Parse(shellTopHTML + remindersHTML + shellBottomHTML)) var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Parse(shellTopHTML + passkeyPageHTML + shellBottomHTML)) var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellTopHTML + voiceHTML + shellBottomHTML)) var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellTopHTML + routinesHTML + shellBottomHTML)) var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMap { m := shellFuncs() m["fmtTime"] = func(t *time.Time) string { if t == nil || t.IsZero() { return "—" } return t.Format("15:04:05") } m["join"] = strings.Join return m }()).Parse(shellTopHTML + traceHTML + shellBottomHTML)) func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { if core == nil { http.Error(w, "history disabled (no -core)", http.StatusServiceUnavailable) return } ctx := r.Context() facts, err := core.RecentFacts(ctx, 200) if err != nil { log.Printf("history: %v", err) http.Error(w, "core read failed", http.StatusBadGateway) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := historyTmpl.Execute(w, struct { Facts []ipc.Fact }{facts}); err != nil { log.Printf("history render: %v", err) } } func handleNotifications(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { if core == nil { http.Error(w, "notifications disabled (no -core)", http.StatusServiceUnavailable) return } ctx := r.Context() nudges, err := core.RecentNudges(ctx, 50) if err != nil { log.Printf("notifications: %v", err) http.Error(w, "notifications error: "+err.Error(), http.StatusBadGateway) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := notificationsTmpl.Execute(w, map[string]any{"Nudges": nudges}); err != nil { log.Printf("notifications template: %v", err) } } func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { if core == nil { http.Error(w, "reminders disabled (no -core)", http.StatusServiceUnavailable) return } ctx := r.Context() reminders, err := core.ListReminders(ctx, 50) if err != nil { log.Printf("reminders: %v", err) http.Error(w, "reminders error: "+err.Error(), http.StatusBadGateway) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := remindersTmpl.Execute(w, map[string]any{"Reminders": reminders}); err != nil { log.Printf("reminders template: %v", err) } } func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { if core == nil { http.Error(w, "routines disabled (no -core)", http.StatusServiceUnavailable) return } ctx := r.Context() var msg string if r.Method == http.MethodPost { action := r.FormValue("action") idStr := r.FormValue("id") var rid int64 if n, _ := fmt.Sscanf(idStr, "%d", &rid); n != 1 { http.Error(w, "invalid id", http.StatusBadRequest) return } switch action { case "dismiss": if err := core.DismissProposedRoutine(ctx, rid); err != nil { log.Printf("routines: dismiss %d: %v", rid, err) http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway) return } msg = "dismissed routine" default: http.Error(w, "unknown action", http.StatusBadRequest) return } } proposed, err := core.ListProposedRoutines(ctx) if err != nil { log.Printf("routines: list: %v", err) http.Error(w, "routines error: "+err.Error(), http.StatusBadGateway) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := routinesTmpl.Execute(w, struct { Msg string Proposed []ipc.ProposedRoutine }{msg, proposed}); err != nil { log.Printf("routines render: %v", err) } } func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { if core == nil { http.Error(w, "trace disabled (no -core)", http.StatusServiceUnavailable) return } ctx := r.Context() trace, err := core.TickTrace(ctx) if err != nil { log.Printf("trace: %v", err) http.Error(w, "core read failed", http.StatusBadGateway) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := traceTmpl.Execute(w, trace); err != nil { log.Printf("trace render: %v", err) } } func handleVoice(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := voiceTmpl.Execute(w, nil); err != nil { log.Printf("voice render: %v", err) } } // stepUpOK is the single decision point for the AuthStepUp gate shared by // POST /tools and POST /api/revert. // // A nil session means WebAuthn is not configured (-webauthn-origin / // -webauthn-rpid unset), so step-up can never be asserted — not merely unmet. // The default is therefore fail-OPEN: gating on an unassertable session would // 403 those surfaces permanently. In that mode the actions rest on the // transport-level auth in front of mavweb (wg+nginx+auth), and main logs a // startup warning naming them. With -require-stepup the same situation fails // CLOSED instead: no assertable step-up ⇒ deny. func stepUpOK(session *webauthn.PasskeySession, requireStepUp bool) bool { if session == nil { return !requireStepUp } return session.IsStepUp() } func handleRevert(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) { if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) return } if core == nil { http.Error(w, "revert disabled (no -core)", http.StatusServiceUnavailable) return } if !stepUpOK(session, requireStepUp) { http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden) return } key := strings.TrimSpace(r.FormValue("key")) if key == "" { http.Error(w, "key required", http.StatusBadRequest) return } ctx := r.Context() newID, err := core.RevertFact(ctx, key) if err != nil { log.Printf("revert %q: %v", key, err) if errors.Is(err, ipc.ErrNoFact) { http.Error(w, "no fact to revert", http.StatusNotFound) return } http.Error(w, "revert failed", http.StatusBadGateway) return } log.Printf("reverted fact for key=%s, new_id=%d", key, newID) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"reverted": true, "new_id": newID}) } // handleTools serves the enable surface (GET) and applies an enable (POST). // POST fields: name, cmd (space-separated argv), destructive (checkbox). cmd is // whitespace-split — argv with embedded spaces isn't supported (ponytail: no // shell-word parsing; the box owner controls this input, quote a wrapper script // if an arg needs spaces). func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) { if core == nil { http.Error(w, "tools disabled (no -core)", http.StatusServiceUnavailable) return } ctx := r.Context() var msg string if r.Method == http.MethodPost { // nil session ⇒ WebAuthn not configured; step-up gate not applicable // (see wiring in main — asserting would be impossible, not just unmet). if session != nil && !session.IsStepUp() { http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden) return } action := r.FormValue("action") name := strings.TrimSpace(r.FormValue("name")) switch action { case "enable": scope := r.FormValue("scope") cmd := strings.Fields(r.FormValue("cmd")) destructive := r.FormValue("destructive") != "" if name == "" || len(cmd) == 0 { http.Error(w, "name and cmd required", http.StatusBadRequest) return } if err := core.EnableTool(ctx, name, cmd, destructive, scope, time.Now()); err != nil { log.Printf("tools: enable %q: %v", name, err) http.Error(w, "enable failed: "+err.Error(), http.StatusBadGateway) return } msg = "enabled " + name case "disable": if name == "" { http.Error(w, "name required", http.StatusBadRequest) return } if err := core.DisableTool(ctx, name); err != nil { log.Printf("tools: disable %q: %v", name, err) http.Error(w, "disable failed: "+err.Error(), http.StatusBadGateway) return } msg = "disabled " + name case "dismiss": if name == "" { http.Error(w, "name required", http.StatusBadRequest) return } if err := core.DeleteTool(ctx, name); err != nil { log.Printf("tools: dismiss %q: %v", name, err) http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway) return } msg = "dismissed " + name default: http.Error(w, "unknown action", http.StatusBadRequest) return } } proposed, err1 := core.ListTools(ctx, "proposed") enabled, err2 := core.ListTools(ctx, "enabled") if err := cmp.Or(err1, err2); err != nil { log.Printf("tools: %v", err) http.Error(w, "core read failed", http.StatusBadGateway) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := toolsTmpl.Execute(w, struct { Msg string Proposed []ipc.Tool Enabled []ipc.Tool }{msg, proposed, enabled}); err != nil { log.Printf("tools render: %v", err) } } func writeWSErr(conn *websocket.Conn, ctx context.Context, msg string) { conn.Write(ctx, websocket.MessageText, []byte(`{"error":"`+msg+`"}`)) } func writeFrame(w io.Writer, v any) error { body, err := json.Marshal(v) if err != nil { return fmt.Errorf("marshal: %w", err) } const maxFrame = 64 << 20 if len(body) > maxFrame { return fmt.Errorf("frame too large: %d", len(body)) } var hdr [4]byte binary.BigEndian.PutUint32(hdr[:], uint32(len(body))) if _, err := w.Write(hdr[:]); err != nil { return err } _, err = w.Write(body) return err } func readFrame(r io.Reader, v any) error { var hdr [4]byte if _, err := io.ReadFull(r, hdr[:]); err != nil { return err } n := binary.BigEndian.Uint32(hdr[:]) const maxFrame = 64 << 20 if n > maxFrame { return fmt.Errorf("frame too large: %d", n) } buf := make([]byte, n) if _, err := io.ReadFull(r, buf); err != nil { return err } return json.Unmarshal(buf, v) } func readOneFrame(r io.Reader) (*voice.Response, *voice.Push, error) { var raw struct { ID uint64 `json:"id"` Result json.RawMessage `json:"r,omitempty"` Error *voice.RpcError `json:"e,omitempty"` Kind voice.PushKind `json:"kind,omitempty"` Params json.RawMessage `json:"p,omitempty"` } if err := readFrame(r, &raw); err != nil { return nil, nil, err } if raw.Kind != "" && raw.ID == 0 { return nil, &voice.Push{Kind: raw.Kind, Params: raw.Params}, nil } return &voice.Response{ID: raw.ID, Result: raw.Result, Error: raw.Error}, nil, nil } func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string) { if r.Method != http.MethodPost { http.Error(w, "POST only", 405) return } body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, err.Error(), 400) return } if len(body) < 4 { http.Error(w, "too short", 400) return } log.Printf("ptt got %d bytes from client", len(body)) pcm := audio.Audio{Format: audio.PCM16kMono, Bytes: body} var d net.Dialer tc, err := d.DialContext(r.Context(), "tcp", voiceAddr) if err != nil { log.Printf("ptt dial voice: %v", err) http.Error(w, "voice unavailable", 503) return } defer tc.Close() req := voice.Request{ ID: uint64(time.Now().UnixNano()), Method: voice.MethodPushToTalk, Params: mustMarshal(voice.PushToTalkReq{ Audio: pcm, Lang: "mixed", Surface: voice.SurfacePCClient, }), } if err := writeFrame(tc, &req); err != nil { log.Printf("ptt write: %v", err) http.Error(w, err.Error(), 500) return } for { resp, push, err := readOneFrame(tc) if err != nil { log.Printf("ptt read: %v", err) http.Error(w, err.Error(), 500) return } if push != nil { continue } if resp.Error != nil { http.Error(w, resp.Error.Message, 500) return } var pttResp voice.PushToTalkResp if err := json.Unmarshal(resp.Result, &pttResp); err != nil { http.Error(w, err.Error(), 500) return } w.Header().Set("Content-Type", "audio/l16;rate=16000;channels=1") w.Header().Set("X-Reply-Text", url.QueryEscape(pttResp.ReplyText)) w.Write(pttResp.ReplyAudio.Bytes) return } } // --- chat page --- // chatTmpl — plain text conversation interface. No JS: form POSTs to /api/chat // and the handler redirects back to /chat with the response. var chatTmpl = template.Must(template.New("chat").Funcs(shellFuncs()).Parse(shellTopHTML + chatPageHTML + shellBottomHTML)) const chatPageHTML = `{{template "shellTop" "chat"}}

Chat

{{if .Error}}
{{.Error}}
{{end}}
{{range .Messages}}
{{if eq .Role "user"}}you{{else}}maven{{end}}: {{.Text}}
{{else}}
start a conversation
{{end}}
{{template "shellBottom"}}` // handleChatPage renders the chat conversation page. func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { if core == nil { http.Error(w, "chat disabled (no -core)", http.StatusServiceUnavailable) return } msgs := []chatMsg{} // Read user message + reply from query params (set by /api/chat redirect). if q := r.URL.Query().Get("q"); q != "" { msgs = append(msgs, chatMsg{Role: "user", Text: q}) } if r := r.URL.Query().Get("r"); r != "" { msgs = append(msgs, chatMsg{Role: "assistant", Text: r}) } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := chatTmpl.Execute(w, struct { Error string Messages []chatMsg }{Messages: msgs}); err != nil { log.Printf("chat render: %v", err) } } // handleChatAPI processes a chat message POST and redirects back to /chat. func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) return } if core == nil { http.Error(w, "chat disabled (no -core)", http.StatusServiceUnavailable) return } text := strings.TrimSpace(r.FormValue("text")) if text == "" { http.Redirect(w, r, "/chat", http.StatusSeeOther) return } reply, err := core.Chat(r.Context(), text) if err != nil { log.Printf("chat api: %v", err) http.Redirect(w, r, "/chat", http.StatusSeeOther) return } http.Redirect(w, r, "/chat?q="+url.QueryEscape(text)+"&r="+url.QueryEscape(reply), http.StatusSeeOther) } // chatMsg — one message in the conversation history. type chatMsg struct { Role string // "user" | "assistant" Text string } func mustMarshal(v any) json.RawMessage { b, err := json.Marshal(v) if err != nil { panic(err) } return b }