diff --git a/cmd/mavweb/chat.go b/cmd/mavweb/chat.go new file mode 100644 index 0000000..383245c --- /dev/null +++ b/cmd/mavweb/chat.go @@ -0,0 +1,92 @@ +package main + +import ( + _ "embed" + "log" + "net/http" + "net/url" + "strings" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/webauthn" +) + +//go:embed chat.html +var chatPageHTML string + +// chatTmpl — plain text conversation interface. No JS: form POSTs to /api/chat +// and the handler redirects back to /chat with the response. +var chatTmpl = parsePage("chat", chatPageHTML, nil) + +// chatMsg — one message in the conversation history. +type chatMsg struct { + Role string // "user" | "assistant" + Text string + // Source — the query source that claimed the turn, shown as a badge beside + // the reply. Empty for a turn no source claimed (V-539). + Source string +} + +// handleChatPage renders the chat conversation page. +func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { + if !requireCore(w, core, "chat") { + 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 reply := r.URL.Query().Get("r"); reply != "" { + msgs = append(msgs, chatMsg{Role: "assistant", Text: reply, Source: r.URL.Query().Get("s")}) + } + renderPage(w, chatTmpl, struct { + Error string + Messages []chatMsg + }{Messages: msgs}) +} + +// handleChatAPI processes a chat message POST and redirects back to /chat. +// +// State-changing, and the widest surface on this server: the text reaches the +// router, the LLM, and through mavend's applyAction the whole action path +// including `act` — so it is gated on the same step-up as POST /tools and +// POST /api/revert (Vikunja #317). With WebAuthn unconfigured the gate is +// fail-open exactly like the others (see stepUpOK); with -require-stepup it +// denies, which is the point of that flag. +func handleChatAPI(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 !requireCore(w, core, "chat") { + return + } + if !stepUpGate(w, session, requireStepUp) { + return + } + text := strings.TrimSpace(r.FormValue("text")) + if text == "" { + http.Redirect(w, r, "/chat", http.StatusSeeOther) + return + } + // One conversation id for the whole web chat, and a different one from + // telegram or the mic. A parked question belongs to the reach that was + // asked; before this, a clarify nobody answered on the web ate the next + // utterance spoken at the mic (Vikunja #466). This server has no + // per-browser session, so every browser tab is the same conversation — + // which is right for a single-owner box. + reply, err := core.Chat(r.Context(), "web", text) + if err != nil { + log.Printf("chat api: %v", err) + http.Redirect(w, r, "/chat", http.StatusSeeOther) + return + } + // The claiming query source rides back on the redirect so the page can show + // it. Empty for a turn no source claimed, which is most of them. + dest := "/chat?q=" + url.QueryEscape(text) + "&r=" + url.QueryEscape(reply.Reply) + if reply.Source != "" { + dest += "&s=" + url.QueryEscape(reply.Source) + } + http.Redirect(w, r, dest, http.StatusSeeOther) +} diff --git a/cmd/mavweb/ecosystem.go b/cmd/mavweb/ecosystem.go index 77a329c..1bb1439 100644 --- a/cmd/mavweb/ecosystem.go +++ b/cmd/mavweb/ecosystem.go @@ -117,8 +117,5 @@ func handleEcosystem(w http.ResponseWriter, r *http.Request, urls ecoURLs, core d.Calls.Rows = rows } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := ecosystemTmpl.Execute(w, d); err != nil { - log.Printf("ecosystem render: %v", err) - } + renderPage(w, ecosystemTmpl, d) } diff --git a/cmd/mavweb/facts.go b/cmd/mavweb/facts.go new file mode 100644 index 0000000..73a847a --- /dev/null +++ b/cmd/mavweb/facts.go @@ -0,0 +1,96 @@ +package main + +import ( + "encoding/json" + "errors" + "log" + "net/http" + "strings" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/webauthn" +) + +// The two fact-writing API routes: POST /api/signal appends a presence +// observation, POST /api/revert voids the latest fact for a key. Neither +// renders a page. + +// 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", +} + +// 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 !requireCore(w, core, "presence ingest") { + 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 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 !requireCore(w, core, "revert") { + return + } + if !stepUpGate(w, session, requireStepUp) { + return + } + + key := strings.TrimSpace(r.FormValue("key")) + if key == "" { + http.Error(w, "key required", http.StatusBadRequest) + return + } + newID, err := core.RevertFact(r.Context(), 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}) +} diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index 0d89d76..ea913b9 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -1,267 +1,23 @@ 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" - "strconv" - "strings" "time" - "github.com/coder/websocket" - "github.com/kami/maven/internal/audio" "github.com/kami/maven/internal/ipc" - "github.com/kami/maven/internal/pattern" - "github.com/kami/maven/internal/tasks" - "github.com/kami/maven/internal/tool" - "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 tasks.html -var tasksHTML string - -//go:embed voice.html -var voiceHTML string - -//go:embed ecosystem.html -var ecosystemHTML string - -//go:embed morning.html -var morningHTML string - -//go:embed events.html -var eventsHTML string - -//go:embed tools.html -var toolsHTML string - -//go:embed routines.html -var routinesHTML string - -//go:embed chat.html -var chatPageHTML string - -// shellHTML — the shell partial every page is wrapped in: "shellTop", the -// "sidebar" it calls, and "shellBottom". It used to be two Go string constants -// with the sidebar assembled by a strings.Builder, which is the one piece of -// markup that was still concatenated in Go. -// -//go:embed shell.html -var shellHTML 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: "Tasks", URL: "/tasks", Key: "tasks"}, - {Label: "Reminders", URL: "/reminders", Key: "reminders"}, - {Label: "Routines", URL: "/routines", Key: "routines"}, - {Label: "Morning", URL: "/morning", Key: "morning"}, - {Label: "Intake", URL: "/events", Key: "events"}, - }, - }, - { - 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: "Model", URL: "/models", Key: "models"}, - {Label: "Passkey", URL: "/auth/passkey", Key: "passkey"}, - }, - }, -} - -// pageIcon returns the ethos-icons.svg symbol id for the given page. The -// sidebar template wraps it in the reference. -func pageIcon(key string) string { - switch key { - case "dash": - return "i-grid" - case "history": - return "i-clock" - case "trace": - return "i-wave" - case "notifications": - return "i-bell" - case "tasks": - return "i-grid" - case "reminders": - return "i-calendar" - case "routines": - return "i-repeat" - case "morning": - return "i-calendar" - case "chat": - return "i-message" - case "voice": - return "i-mic" - case "ecosystem": - return "i-grid" - case "tools": - return "i-settings" - case "models": - return "i-wave" - case "passkey": - return "i-lock" - default: - return "i-search" - } -} - -// 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 "tasks": - return "Tasks" - case "reminders": - return "Reminders" - case "routines": - return "Routines" - case "morning": - return "Morning Routines" - case "chat": - return "Chat" - case "voice": - return "Voice" - case "ecosystem": - return "Ecosystem" - case "tools": - return "Tools" - case "models": - return "Resident Model" - case "passkey": - return "Passkey" - default: - return key - } -} - -// shellFuncs returns the FuncMap shared by every server-rendered page template. -func shellFuncs() template.FuncMap { - return template.FuncMap{ - "pageTitle": pageTitle, - "pageIcon": pageIcon, - "sidebarSections": func() any { return sidebarSections }, - "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(shellHTML + dashHTML)) - -// 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(shellHTML + ecosystemHTML)) - -// eventsTmpl — the unified intake journal (Vikunja #283), read-only. Same -// shape as trace.html and morning.html: server-rendered, refreshed on reload. -var eventsTmpl = template.Must(template.New("events").Funcs(shellFuncs()).Parse(shellHTML + eventsHTML)) - -// morningTmpl — read-only view of today's checklist state per configured -// morning routine (internal/morning). Same shape as trace.html: a plain -// server-rendered page, refreshed on reload — no live-update loop, since -// checklist state changes on the scale of minutes, not seconds. -var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellHTML + morningHTML)) - 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") @@ -317,6 +73,23 @@ func main() { } } + // stepUpSession stays nil unless the passkey endpoints are wired below — it + // can only ever be asserted via AssertFinish, so gating POST /tools on it + // without those endpoints would make tool enable/disable permanently 403. + // Declared here because the gated routes close over it. + var stepUpSession *webauthn.PasskeySession + + // The two handler shapes on this server, so the route table below reads as + // a table rather than as twenty identical closures. Both close over `core` + // and `stepUpSession`, which is what lets the gated routes be registered + // before the passkey endpoints decide whether step-up exists at all. + corePage := func(h func(http.ResponseWriter, *http.Request, ipc.CoreAPI)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { h(w, r, core) } + } + gatedPage := func(h func(http.ResponseWriter, *http.Request, ipc.CoreAPI, *webauthn.PasskeySession, bool)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { h(w, r, core, stepUpSession, *requireStepUp) } + } + mux := http.NewServeMux() sub, err := fs.Sub(staticFiles, "static") @@ -332,9 +105,6 @@ func main() { handleVoice(w, r) })) - // /ws and /api/ptt are registered further down, next to /api/chat: they - // carry the same step-up gate and so need stepUpSession, which is only - // built once the passkey endpoints are wired. mux.HandleFunc("/api/ping", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("pong")) }) @@ -346,9 +116,7 @@ func main() { 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("/api/signal", corePage(handleSignal)) // Off unless configured: no token, no route — an unconfigured ingest is not // a 503 waiting to be probed, it does not exist. if *ambientToken != "" { @@ -357,49 +125,35 @@ func main() { }) log.Printf("mavweb: ambient notification ingest enabled at POST /api/ambient") } - 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) - }) + + // The read surfaces. Every one of them 503s without -core. + mux.HandleFunc("/dash", corePage(handleDash)) + mux.HandleFunc("/history", corePage(handleHistory)) + mux.HandleFunc("/trace", corePage(handleTrace)) + mux.HandleFunc("/notifications", corePage(handleNotifications)) + mux.HandleFunc("/reminders", corePage(handleReminders)) + mux.HandleFunc("/morning", corePage(handleMorning)) + mux.HandleFunc("/events", corePage(handleEvents)) // /tasks — capture + review. POST is not step-up gated; see handleTasks for // why a task write is not in the same class as /tools or /routines. - mux.HandleFunc("/tasks", func(w http.ResponseWriter, r *http.Request) { - handleTasks(w, r, core) - }) - mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) { - handleMorning(w, r, core) - }) - mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) { - handleEvents(w, r, core) - }) + mux.HandleFunc("/tasks", corePage(handleTasks)) + // GET /chat only renders the page and echoes back the q/r query params the + // POST redirect set — nothing to gate. + mux.HandleFunc("/chat", corePage(handleChatPage)) + ecoURLsCfg := ecoURLs{nexus: *nexusURL, praxis: *praxisURL, hexis: *hexisURL} mux.HandleFunc("/ecosystem", func(w http.ResponseWriter, r *http.Request) { handleEcosystem(w, r, ecoURLsCfg, core) }) + // ----- 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 - + // endpoints return 503 and step-up is unavailable (FloorSession), and + // /tools falls back to the transport-level auth it sits behind + // (wg+nginx+auth), same as before step-up existed. if *pkOrigin != "" && *pkRPID != "" && core != nil { stepUpSession = webauthn.NewPasskeySession(5 * time.Minute) pk, err := newPasskeyHandle(webauthn.Config{ @@ -415,53 +169,9 @@ func main() { mux.HandleFunc("/auth/webauthn/register/finish", pk.RegisterFinish) mux.HandleFunc("/auth/webauthn/assert/begin", pk.AssertBegin) mux.HandleFunc("/auth/webauthn/assert/finish", pk.AssertFinish) + } else { + logUnguardedSurfaces(*requireStepUp) } - if stepUpSession == nil { - // One surface per line: these are read in a terminal at the moment - // someone is deciding whether the box is safe to expose. - surfaces := []string{ - "POST /tools defines arbitrary argv via name+cmd, which internal/tool then EXECUTES", - "POST /routines accepting schedules recurring firing", - "POST /models chooses the resident model that routes and words every turn", - "POST /api/revert voids the latest fact for a key", - "POST /api/chat reaches the router, the LLM and, through applyAction, the act path", - "POST /api/ptt the same, from audio", - "GET /ws the same, streamed", - } - if *requireStepUp { - log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set. These surfaces will be DENIED (403):") - } else { - log.Printf("SECURITY WARNING: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset). These surfaces are UNGUARDED:") - } - for _, s := range surfaces { - log.Printf("SECURITY: %s", s) - } - if *requireStepUp { - log.Printf("SECURITY: set -webauthn-origin and -webauthn-rpid to enable passkey step-up.") - } else { - log.Printf("SECURITY: they rest on the transport-level auth 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 (docs/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) - }) - // /routines — the authed accept surface. Registered here, next to /tools, - // because accepting shares the same step-up gate. - mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) { - handleRoutines(w, r, core, stepUpSession, *requireStepUp) - }) - // /models — the resident-model surface (Vikunja #250). Same step-up gate as - // /tools, and for a comparable reason: which model is loaded decides how every - // utterance is routed and how every reply is worded. GET is read-only. - mux.HandleFunc("/models", func(w http.ResponseWriter, r *http.Request) { - handleModels(w, r, core, swapConn, stepUpSession, *requireStepUp) - }) // State-changing routes on this server, and their gate (Vikunja #317): // @@ -490,16 +200,18 @@ func main() { // assertion per turn whenever WebAuthn is configured. A session-scoped // assertion covering a run of turns is the right shape and is its own task. // - // GET /chat only renders the page and echoes back the q/r query params the - // POST redirect set — nothing to gate. - 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, stepUpSession, *requireStepUp) - }) - mux.HandleFunc("/api/revert", func(w http.ResponseWriter, r *http.Request) { - handleRevert(w, r, core, stepUpSession, *requireStepUp) + // /tools is the authed enable surface: maven proposes acts she can't run, + // and this page is where a human reviews and enables them. Enabling is the + // boundary-moving act (docs/design.md § Tool registration — drafting is + // suggest, enabling is act), so it lives ONLY here, never on voice or chat. + // /models is the same tier for a comparable reason: which model is loaded + // decides how every utterance is routed and how every reply is worded. + mux.HandleFunc("/tools", gatedPage(handleTools)) + mux.HandleFunc("/routines", gatedPage(handleRoutines)) + mux.HandleFunc("/api/chat", gatedPage(handleChatAPI)) + mux.HandleFunc("/api/revert", gatedPage(handleRevert)) + mux.HandleFunc("/models", func(w http.ResponseWriter, r *http.Request) { + handleModels(w, r, core, swapConn, stepUpSession, *requireStepUp) }) mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { handleWS(w, r, *voiceAddr, stepUpSession, *requireStepUp) @@ -524,1362 +236,30 @@ func main() { } } -func handleWS(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) { - if !stepUpOK(session, requireStepUp) { - http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden) - return - } - 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 - m["capability"] = func(t ipc.Tool) string { return tool.CapabilityOf(t).String() } - m["risk"] = func(t ipc.Tool) string { return string(tool.RiskOf(t)) } - return m -}()).Parse(shellHTML + toolsHTML)) - -var historyTmpl = template.Must(template.New("history").Funcs(shellFuncs()).Parse(shellHTML + historyHTML)) - -var notificationsTmpl = template.Must(template.New("notifications").Funcs(shellFuncs()).Parse(shellHTML + notificationsHTML)) - -var remindersTmpl = template.Must(template.New("reminders").Funcs(shellFuncs()).Parse(shellHTML + remindersHTML)) - -var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Parse(shellHTML + passkeyPageHTML)) - -var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellHTML + voiceHTML)) - -var tasksTmpl = template.Must(template.New("tasks").Funcs(shellFuncs()).Parse(shellHTML + tasksHTML)) - -// routinesTmpl — the proposed-routine review surface. One row per thing maven -// noticed, in her words, with at most two actions: accept or dismiss. -var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellHTML + routinesHTML)) - -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(shellHTML + traceHTML)) - -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 - } - // The outbox, on the page that already answers "what did she send". - // A failed or dropped attempt is why she went quiet, and until now it was - // recorded and unreadable (Vikunja #390). Filter with ?status=dropped. - status := r.URL.Query().Get("status") - attempts, err := core.DeliveryAttempts(ctx, status, 50) - if err != nil { - // The nudge list is still worth showing, so this is a note on the page - // rather than a dead page. - log.Printf("notifications: delivery attempts: %v", err) - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := notificationsTmpl.Execute(w, map[string]any{ - "Nudges": nudges, - "Attempts": deliveryRows(attempts), - "Status": status, - }); err != nil { - log.Printf("notifications template: %v", err) - } -} - -// deliveryRow is one outbox line, with every timestamp already formatted so -// the template holds no date logic — same shape as taskRow. -type deliveryRow struct { - Kind string - Target string - Channel string - Status string - Created string - Completed string -} - -func deliveryRows(as []ipc.DeliveryAttempt) []deliveryRow { - out := make([]deliveryRow, 0, len(as)) - for _, a := range as { - target := a.Rule - if target == "" && a.ReminderID != 0 { - target = "reminder #" + strconv.FormatInt(a.ReminderID, 10) - } - row := deliveryRow{ - Kind: a.Kind, - Target: target, - Channel: a.Channel, - Status: a.Status, - Created: a.Created.Format("02.01 15:04"), - } - if a.Completed != nil { - row.Completed = a.Completed.Format("15:04") - } - out = append(out, row) - } - return out -} - -// reminderRow is one line on /reminders, with the payload unwrapped and both -// timestamps already in his clock. -// -// The page rendered `{{.Payload}}` and the UTC instant, so a reminder read -// `{"text":"выпить таблетки"}` and fired an hour off what he was told -// (Vikunja #469). Neither is a formatting nicety: the envelope is an internal -// shape he never chose, and a time on a page he reads is the time on his wall. -type reminderRow struct { - Created string - Fires string - Status string - Text string -} - -// reminderText unwraps the {"text":...} payload the router writes. -// -// A copy of store.ReminderText rather than a call to it, because mavweb is one -// of the pure-Go daemons and internal/store carries the CGO sqlite driver. The -// ipc DTO is decoupled from the store on purpose, so the unwrap belongs to -// whoever renders it. Payload that is not that shape is shown as he said it. -func reminderText(payload string) string { - var m map[string]any - if err := json.Unmarshal([]byte(payload), &m); err == nil { - if t, ok := m["text"]; ok { - if s, isStr := t.(string); isStr && s != "" { - return s - } - } - } - return strings.TrimSpace(payload) -} - -func reminderRows(rs []ipc.Reminder) []reminderRow { - out := make([]reminderRow, 0, len(rs)) - for _, r := range rs { - out = append(out, reminderRow{ - Created: r.CreatedTs.Local().Format("02 Jan 15:04"), - Fires: r.FireTs.Local().Format("02 Jan 15:04"), - Status: r.Status, - Text: reminderText(r.Payload), - }) - } - return out -} - -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": reminderRows(reminders)}); err != nil { - log.Printf("reminders template: %v", err) - } -} - -// now — the wall clock, indirected so the task page can be rendered at a fixed -// instant in a test. internal/tasks is pure and the daemon path already ranks -// through a clock it is handed; the page had no reason to be the one surface -// that could only be tested at whatever time it happened to run. -var now = time.Now - -// resolvedShown — how many finished tasks the page renders. The list is -// history, it only grows, and the rows below the first screen are read by -// nobody. -const resolvedShown = 50 - -// taskRow is one line on /tasks, with every timestamp already formatted so the -// template holds no date logic. -type taskRow struct { - ID int64 - Text string - Source string - Evidence string - Status string - Due string - Created string - Resolved string - ResolvedBy string - // DueValue and Weight are the raw values the edit form posts back - // (Vikunja #509). Due above is for reading and says "—" for no date; a - // date input needs "2026-08-07" or the empty string. - DueValue string - Weight int - // Why — the ranker's reason for this row's position (Vikunja #129), in - // Russian, empty when nothing distinguished the task. Blank is the honest - // rendering: he never said this one mattered more. - Why string -} - -// handleTasks serves the task review surface (GET) and the five writes it -// offers (POST): add, edit, confirm, done, drop. -// -// Not step-up gated, unlike /tools and /routines, and the difference is the -// point: enabling a tool defines argv Maven will execute, and accepting a -// routine hands the tick loop a new standing reason to interrupt him. A task is -// neither — nothing in the tick loop reads the tasks table, so the worst a -// weaker caller can do here is write a line onto a list he reads himself. It -// still sits behind whatever transport auth fronts mavweb, like every other -// page. -// -// "edit" was re-argued on the same terms rather than inheriting the exemption -// (Vikunja #509), and it stays ungated. It rewrites a line on a list he reads -// himself, the same blast radius "drop" already has on this page, and the store -// refuses the two edits that would cost something: a resolved task keeps the -// text it was finished under, and a text collision with another live row is -// named instead of merged. -// -// "confirm" is the only interesting move: it promotes a candidate Maven derived -// from something she read into work he owns. That review step is why derived -// tasks are captured as candidates in the first place. -func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { - if core == nil { - http.Error(w, "tasks disabled (no -core)", http.StatusServiceUnavailable) - return - } - ctx := r.Context() - var msg, errMsg string - if r.Method == http.MethodPost { - var err error - msg, err = applyTaskPost(ctx, core, r) - if err != nil { - log.Printf("tasks: %v", err) - errMsg = err.Error() - } - } - - all, err := core.ListTasks(ctx, "") - if err != nil { - log.Printf("tasks: list: %v", err) - http.Error(w, "tasks error: "+err.Error(), http.StatusBadGateway) - return - } - // Live rows are ordered by the same ranker the spoken list uses, so the page - // and the voice reply can never disagree about what comes first. Resolved - // rows keep store order (newest first) — ranking finished work is pointless. - var live []tasks.Item - var resolved []taskRow - resolvedTotal := 0 - for _, t := range all { - switch t.Status { - case "candidate", "open": - live = append(live, tasks.Item{ - ID: t.ID, Text: t.Text, Status: t.Status, - Created: t.CreatedTs, Due: t.Due, Weight: t.Weight, - }) - default: - resolvedTotal++ - // Finished work is history, and the history only grows. The page - // showed every row that ever existed, which is a page that gets - // slower every month for a section nobody reads past the top of. - if len(resolved) >= resolvedShown { - continue - } - resolved = append(resolved, taskRow{ - ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence, - Status: t.Status, Created: fmtTaskTime(&t.CreatedTs), - Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved), - ResolvedBy: t.ResolvedBy, - }) - } - } - byID := make(map[int64]ipc.Task, len(all)) - for _, t := range all { - byID[t.ID] = t - } - var cands, open []taskRow - for _, r := range tasks.Rank(live, now()) { - t := byID[r.ID] - row := taskRow{ - ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence, - Status: t.Status, Created: fmtTaskTime(&t.CreatedTs), - Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved), - DueValue: fmtTaskDateValue(t.Due), Weight: t.Weight, - Why: r.Reason, - } - if t.Status == "candidate" { - // A candidate's due date is Maven's reading of a mail, so its - // ranking reason is not shown as if he had set a priority. - row.Why = "" - cands = append(cands, row) - } else { - open = append(open, row) - } - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := tasksTmpl.Execute(w, struct { - Msg, Err string - Stalls []tasks.Stall - Candidates []taskRow - Open []taskRow - Resolved []taskRow - ResolvedMore bool - }{msg, errMsg, tasks.Stalls(live, now()), cands, open, resolved, resolvedTotal > len(resolved)}); err != nil { - log.Printf("tasks render: %v", err) - } -} - -// applyTaskPost performs one write and returns the message to show. A bad -// request returns an error, which the page renders inline rather than as a -// bare 400 — this is a form surface, not an API. -func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) { - action := r.FormValue("action") - if action == "add" { - text := strings.TrimSpace(r.FormValue("text")) - if text == "" { - return "", errors.New("empty task text") - } - req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: now()} - wgt, err := formWeight(r) - if err != nil { - return "", err - } - req.Weight = wgt - due, err := formDue(r, now()) - if err != nil { - return "", err - } - req.Due = due - resp, err := core.CaptureTask(ctx, req) - if err != nil { - return "", err - } - if resp.Promoted { - return "confirmed a candidate maven had found", nil - } - if !resp.Created { - return "already on the list", nil - } - return "added task", nil - } - - id, err := strconv.ParseInt(r.FormValue("id"), 10, 64) - if err != nil { - return "", errors.New("invalid id") - } - - if action == "promote" { - msg, err := promoteCandidate(ctx, core, r, id) - if err != nil { - return "", err - } - return msg, nil - } - - if action == "edit" { - // The three fields capture set, and only those (Vikunja #509). Status - // is not editable here: that ladder is one-way and has its own buttons. - text := strings.TrimSpace(r.FormValue("text")) - if text == "" { - return "", errors.New("empty task text") - } - wgt, err := formWeight(r) - if err != nil { - return "", err - } - due, err := formDue(r, now()) - if err != nil { - return "", err - } - switch err := core.EditTask(ctx, id, text, due, wgt); { - case err == nil: - return "saved task", nil - case errors.Is(err, ipc.ErrTaskDuplicate): - // Naming the collision instead of merging: two live rows carry two - // provenances, and picking one is not the page's call. - return "", errors.New("another open task already says this — drop one of the two") - case errors.Is(err, ipc.ErrTaskResolved): - return "", errors.New("a resolved task keeps the text it was finished under") - default: - return "", err - } - } - - var status, msg string - switch action { - case "confirm": - status, msg = "open", "confirmed task" - case "done": - status, msg = "done", "task done" - case "drop": - status, msg = "dropped", "dropped task" - default: - return "", fmt.Errorf("unknown action %q", action) - } - if err := core.SetTaskStatus(ctx, id, status, now(), "tap:web"); err != nil { - if errors.Is(err, ipc.ErrTaskNoDoneWhen) { - // The refusal has to name what is missing, or the button looks - // broken. The field it asks for arrives with the intake form - // (Vikunja #511). - return "", errors.New("write a definition of done before confirming this candidate") - } - return "", err - } - return msg, nil -} - -// fmtTaskDateValue renders a due date the way requires, or -// "" for no date. Separate from fmtTaskDate, which renders it for reading. -// promoteCandidate turns a candidate into open work with the three things the -// board needs (Vikunja #511): a definition of done, an optional blocker, and an -// optional date. -// -// The definition of done is required, and the refusal is the store's — this -// only reaches it in a readable order. The blocker is a NAME here and an entity -// id in the row: identity lives in Nexus, so the name is resolved first and a -// name Nexus cannot resolve stops the promotion instead of being stored. -// -// A date set here writes a reminder, which is the one unprompted delivery the -// persona allows: he asked to be told, on a day he named. -func promoteCandidate(ctx context.Context, core ipc.CoreAPI, r *http.Request, id int64) (string, error) { - doneWhen := strings.TrimSpace(r.FormValue("done_when")) - if doneWhen == "" { - return "", errors.New("write a definition of done — what has to be true for this to be finished") - } - text := strings.TrimSpace(r.FormValue("text")) - if text == "" { - return "", errors.New("empty task text") - } - due, err := formDue(r, now()) - if err != nil { - return "", err - } - - blockedOn := "" - if name := strings.TrimSpace(r.FormValue("blocked_on")); name != "" { - ref, err := core.ResolveEntity(ctx, name, []string{"person"}) - switch { - case errors.Is(err, ipc.ErrNotImplemented): - return "", errors.New("no identity service here, so blocked-on cannot be stored — leave it empty") - case errors.Is(err, ipc.ErrNoEntity): - return "", fmt.Errorf("nexus does not know %q", name) - case err != nil: - return "", fmt.Errorf("resolving %q: %w", name, err) - case ref.Ambiguous: - // Asking, not picking: a task blocked on the wrong person is a - // mistake nobody can see afterwards. - return "", fmt.Errorf("%q matches %s — say which", name, strings.Join(ref.Candidates, ", ")) - } - blockedOn = ref.ID - } - - if err := core.SetTaskFields(ctx, id, doneWhen, blockedOn); err != nil { - return "", err - } - if due != nil { - wgt, err := formWeight(r) - if err != nil { - return "", err - } - if err := core.EditTask(ctx, id, text, due, wgt); err != nil { - return "", err - } - } - if err := core.SetTaskStatus(ctx, id, "open", now(), "tap:web"); err != nil { - if errors.Is(err, ipc.ErrTaskNoDoneWhen) { - return "", errors.New("write a definition of done before confirming this candidate") - } - return "", err - } - if due == nil { - return "confirmed", nil - } - // A date-only field has no hour. Nine in the morning, because the reminder - // is about a day's work and being told at midnight is being told the night - // before. - fire := time.Date(due.Year(), due.Month(), due.Day(), 9, 0, 0, 0, due.Location()) - if _, err := core.CreateReminder(ctx, fire, text, ""); err != nil { - // The task IS promoted; only the reminder failed. Saying "confirmed" - // and nothing else would leave him expecting a nudge that will not come. - return "", fmt.Errorf("confirmed, but the reminder did not save: %w", err) - } - return "confirmed, and maven will remind you that morning", nil -} - -// formWeight reads the importance select. Out-of-range clamps rather than -// rejects — a bad select is not worth a 400 — but trailing garbage is refused, -// because strconv is not Sscanf and "3junk" is not a 3. -func formWeight(r *http.Request) (int, error) { - v := r.FormValue("weight") - if v == "" { - return 0, nil - } - wgt, err := strconv.Atoi(v) - if err != nil || wgt < 0 { - return 0, fmt.Errorf("bad weight %q", v) - } - if wgt > tasks.MaxWeight { - wgt = tasks.MaxWeight - } - return wgt, nil -} - -// formDue reads the date input. An empty field is nil, which on an edit means -// "clear the date" — the form has no other way to say it. -func formDue(r *http.Request, now time.Time) (*time.Time, error) { - d := r.FormValue("due") - if d == "" { - return nil, nil - } - due, err := time.ParseInLocation("2006-01-02", d, now.Location()) - if err != nil { - return nil, fmt.Errorf("bad due date %q", d) - } - return &due, nil -} - -func fmtTaskDateValue(t *time.Time) string { - if t == nil || t.IsZero() { - return "" - } - return t.Local().Format("2006-01-02") -} - -func fmtTaskTime(t *time.Time) string { - if t == nil || t.IsZero() { - return "—" - } - return t.Local().Format("02 Jan 15:04") -} - -func fmtTaskDate(t *time.Time) string { - if t == nil || t.IsZero() { - return "—" - } - return t.Local().Format("02 Jan") -} - -// routineView is one line on the page: what maven noticed, in her words, and -// how long ago she noticed it. A view model, not a database row — the template -// never formats an interval or a timestamp itself. -type routineView struct { - ID int64 - Phrase string - Noticed string -} - -// handleRoutines serves the routine review surface (GET) and answers a -// proposal (POST id + action=accept|dismiss). -// -// Accept is gated at step-up, the same tier as enabling a tool: saying yes -// hands the trigger loop a new standing reason to speak to the human, so it -// moves the boundary and only an authed surface may do it. Dismiss is not -// gated — it only ever removes a reason to speak, so the worst a weaker caller -// can do is make maven quieter. -func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) { - 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") - // "seed" is the one action with no routine to act on — it is what - // MAKES a routine (Vikunja #518), so it runs before the id parse. It - // lives on this route rather than a page of its own because it is - // already the step-up-gated surface for this table, and a second gated - // surface is a second thing to get wrong. - if action == "seed" { - if !stepUpOK(session, requireStepUp) { - http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden) - return - } - out, err := seedRoutineEvent(ctx, core, r) - if err != nil { - log.Printf("routines: seed: %v", err) - http.Error(w, "seed failed: "+err.Error(), http.StatusBadGateway) - return - } - msg = out - } else { - 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 "accept": - if !stepUpOK(session, requireStepUp) { - http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden) - return - } - if err := acceptRoutine(ctx, core, rid); err != nil { - log.Printf("routines: accept %d: %v", rid, err) - http.Error(w, "accept failed: "+err.Error(), http.StatusBadGateway) - return - } - msg = "accepted routine — maven will remind you" - 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 []routineView - }{msg, toRoutineViews(proposed)}); err != nil { - log.Printf("routines render: %v", err) - } -} - -// toRoutineViews turns the wire rows into view models. The phrase comes from -// pattern.PhraseRoutine so the page says the same thing maven's voice says. -func toRoutineViews(rs []ipc.ProposedRoutine) []routineView { - out := make([]routineView, 0, len(rs)) - for _, r := range rs { - p := pattern.ProposedRoutine{Action: r.Action, Object: r.Object, IntervalDays: r.IntervalDays} - noticed := "just now" - if r.CreatedTs > 0 { - noticed = time.Since(time.UnixMilli(r.CreatedTs)).Round(time.Minute).String() + " ago" - } - out = append(out, routineView{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed}) - } - return out -} - -// acceptRoutine marks a proposal accepted. This page is the ONLY surface that -// may do it (Vikunja #367): accepting gives the tick loop a standing new -// reason to speak, which DESIGN.md puts at layer 3, and the button here is -// behind step-up. Voice can park the question and dismiss, never accept. -// seedRoutineEvent drives one backdated fact write through core (Vikunja #518), -// so the pattern detector can be exercised against a running daemon instead of -// over real days. Refused unless mavend was started with -allow-seed; on an -// ordinary box the error says so and nothing is written. -// -// Takes "ago" rather than an absolute timestamp — hours before now, as a float -// so a QA sitting can space four seeds three hours apart without doing clock -// arithmetic. The detector's floor is two hours, and "0" is a legal answer -// meaning now. -func seedRoutineEvent(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) { - key := strings.TrimSpace(r.FormValue("key")) - value := strings.TrimSpace(r.FormValue("value")) - if key == "" || value == "" { - return "", errors.New("seed needs a key and a value") - } - agoHours, err := strconv.ParseFloat(strings.TrimSpace(r.FormValue("ago")), 64) - if err != nil { - return "", fmt.Errorf("seed: bad ago (hours before now): %w", err) - } - if agoHours < 0 { - return "", errors.New("seed: ago is hours BEFORE now, so it cannot be negative") - } - resp, err := core.SeedEvent(ctx, ipc.SeedEventReq{ - Key: key, - Value: value, - Ts: time.Now().Add(-time.Duration(agoHours * float64(time.Hour))), - }) - if err != nil { - return "", err - } - if !resp.Extracted { - return fmt.Sprintf("wrote fact %d, but %q is not in the action lexicon — no event, no pattern", resp.FactID, value), nil - } - if !resp.Proposed { - return fmt.Sprintf("seeded %s/%s (fact %d, event %d) — not enough yet to propose", resp.Action, resp.Object, resp.FactID, resp.EventID), nil - } - return fmt.Sprintf("seeded %s/%s and PROPOSED routine %d, every %.1f days", resp.Action, resp.Object, resp.RoutineID, resp.IntervalDays), nil -} - -func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error { - proposed, err := core.ListProposedRoutines(ctx) - if err != nil { - return err - } - var found *ipc.ProposedRoutine - for i := range proposed { - if proposed[i].ID == id { - found = &proposed[i] - break - } - } - if found == nil { - return errors.New("no such proposed routine") - } - - // No reminder is created here. Accepting only flips the status; the tick - // loop reads accepted routines and nudges on the interval (Vikunja #366). - // The old code made a one-shot reminder, so a non-weekly routine fired - // once and then went quiet forever. - return core.AcceptProposedRoutine(ctx, id) -} - -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 - } - // The turn records share this page rather than getting one of their own - // (V-564): both answer the same question — who won, who lost and why — and - // one is about nudges while the other is about utterances. A read failure - // here is not fatal to the page: the rule trace above it still renders, and - // a daemon too old to know the method is the ordinary case during a rolling - // deploy. - turns, err := core.TurnDecisions(ctx, 25) - if err != nil { - log.Printf("trace: turn decisions: %v", err) - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := traceTmpl.Execute(w, traceData{Tick: trace, Turns: turns}); err != nil { - log.Printf("trace render: %v", err) - } -} - -// traceData — what trace.html renders: the last tick's rule arbitration and the -// last turns' claim arbitration. -type traceData struct { - Tick ipc.TickTrace - Turns []ipc.TurnDecision -} - -func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { - if core == nil { - http.Error(w, "morning disabled (no -core)", http.StatusServiceUnavailable) - return - } - ctx := r.Context() - status, err := core.MorningStatus(ctx) - if err != nil { - log.Printf("morning: %v", err) - http.Error(w, "core read failed", http.StatusBadGateway) - return - } - view := morningView{Routines: status} - // The day plan (#128) shows on this page because it is the same question at - // a different scale. A plan read that fails must not take the checklist - // down with it — the page degrades to what it had before. - plan, err := core.DayPlan(ctx) - if err != nil { - log.Printf("morning: day plan: %v", err) - view.PlanErr = err.Error() +// logUnguardedSurfaces names, at startup, what step-up would have covered had +// WebAuthn been configured. One surface per line: these are read in a terminal +// at the moment someone is deciding whether the box is safe to expose. +func logUnguardedSurfaces(requireStepUp bool) { + surfaces := []string{ + "POST /tools defines arbitrary argv via name+cmd, which internal/tool then EXECUTES", + "POST /routines accepting schedules recurring firing", + "POST /models chooses the resident model that routes and words every turn", + "POST /api/revert voids the latest fact for a key", + "POST /api/chat reaches the router, the LLM and, through applyAction, the act path", + "POST /api/ptt the same, from audio", + "GET /ws the same, streamed", + } + if requireStepUp { + log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set. These surfaces will be DENIED (403):") } else { - view.Plan = &plan + log.Printf("SECURITY WARNING: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset). These surfaces are UNGUARDED:") } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := morningTmpl.Execute(w, view); err != nil { - log.Printf("morning render: %v", err) + for _, s := range surfaces { + log.Printf("SECURITY: %s", s) } -} - -// morningView — what /morning renders: today's plan on top, the checklist -// state under it. PlanErr is set instead of Plan when the core could not build -// a plan, so the page says so rather than showing an empty day. -type morningView struct { - Plan *ipc.DayPlan - PlanErr string - Routines []ipc.MorningRoutineStatus -} - -// eventsView — what /events renders. Err is set instead of Events when the -// core could not serve the journal, so the page says why rather than showing an -// empty intake and implying nothing arrived. -type eventsView struct { - Events []ipc.IntakeEvent - Err string -} - -// eventsPageLimit — how many envelopes the page shows. The ring holds more; a -// page is for scanning what just happened, not for archaeology. -const eventsPageLimit = 200 - -func handleEvents(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { - if core == nil { - http.Error(w, "intake journal disabled (no -core)", http.StatusServiceUnavailable) - return - } - var view eventsView - evs, err := core.RecentEvents(r.Context(), eventsPageLimit) - if err != nil { - log.Printf("events: %v", err) - view.Err = err.Error() + if requireStepUp { + log.Printf("SECURITY: set -webauthn-origin and -webauthn-rpid to enable passkey step-up.") } else { - view.Events = evs - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := eventsTmpl.Execute(w, view); err != nil { - log.Printf("events render: %v", err) + log.Printf("SECURITY: they rest on the transport-level auth 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.") } } - -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 { - if !stepUpOK(session, requireStepUp) { - 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 - } - // MCP is off by default and an older core may not know the method at all, - // so a failure here renders an empty section rather than breaking the page. - servers, err := core.MCPServers(ctx) - if err != nil { - log.Printf("tools: mcp servers: %v", err) - servers = nil - } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - // Enabled rows are shown grouped by capability domain (Vikunja #452). A - // flat list stops answering "what can she do to the house" somewhere - // around fifteen rows, and that is the question this page exists for. - if err := toolsTmpl.Execute(w, struct { - Msg string - Proposed []ipc.Tool - Enabled []ipc.Tool - Groups []tool.CapabilityGroup - MCP []ipc.MCPServerStatus - }{msg, proposed, enabled, tool.GroupByDomain(enabled), servers}); 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, session *webauthn.PasskeySession, requireStepUp bool) { - if r.Method != http.MethodPost { - http.Error(w, "POST only", 405) - return - } - if !stepUpOK(session, requireStepUp) { - http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden) - 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") - // PathEscape, not QueryEscape (Vikunja #533). QueryEscape writes a space - // as "+", which is form encoding, and the client decodes this header - // with decodeURIComponent, which only knows "%20" — so every space in a - // spoken reply reached the on-page log as a plus sign. PathEscape is the - // flavour decodeURIComponent actually reverses, which keeps the encoding - // a property of the header rather than something the client has to know. - w.Header().Set("X-Reply-Text", url.PathEscape(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(shellHTML + chatPageHTML)) - -// 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 reply := r.URL.Query().Get("r"); reply != "" { - msgs = append(msgs, chatMsg{Role: "assistant", Text: reply, Source: r.URL.Query().Get("s")}) - } - 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. -// -// State-changing, and the widest surface on this server: the text reaches the -// router, the LLM, and through mavend's applyAction the whole action path -// including `act` — so it is gated on the same step-up as POST /tools and -// POST /api/revert (Vikunja #317). With WebAuthn unconfigured the gate is -// fail-open exactly like the others (see stepUpOK); with -require-stepup it -// denies, which is the point of that flag. -func handleChatAPI(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, "chat disabled (no -core)", http.StatusServiceUnavailable) - return - } - if !stepUpOK(session, requireStepUp) { - http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden) - return - } - text := strings.TrimSpace(r.FormValue("text")) - if text == "" { - http.Redirect(w, r, "/chat", http.StatusSeeOther) - return - } - // One conversation id for the whole web chat, and a different one from - // telegram or the mic. A parked question belongs to the reach that was - // asked; before this, a clarify nobody answered on the web ate the next - // utterance spoken at the mic (Vikunja #466). This server has no - // per-browser session, so every browser tab is the same conversation — - // which is right for a single-owner box. - reply, err := core.Chat(r.Context(), "web", text) - if err != nil { - log.Printf("chat api: %v", err) - http.Redirect(w, r, "/chat", http.StatusSeeOther) - return - } - // The claiming query source rides back on the redirect so the page can show - // it. Empty for a turn no source claimed, which is most of them. - dest := "/chat?q=" + url.QueryEscape(text) + "&r=" + url.QueryEscape(reply.Reply) - if reply.Source != "" { - dest += "&s=" + url.QueryEscape(reply.Source) - } - http.Redirect(w, r, dest, http.StatusSeeOther) -} - -// chatMsg — one message in the conversation history. -type chatMsg struct { - Role string // "user" | "assistant" - Text string - // Source — the query source that claimed the turn, shown as a badge beside - // the reply. Empty for a turn no source claimed (V-539). - Source string -} - -func mustMarshal(v any) json.RawMessage { - b, err := json.Marshal(v) - if err != nil { - panic(err) - } - return b -} diff --git a/cmd/mavweb/models.go b/cmd/mavweb/models.go index ded6af1..2b38bcd 100644 --- a/cmd/mavweb/models.go +++ b/cmd/mavweb/models.go @@ -2,8 +2,8 @@ package main import ( "context" + _ "embed" "errors" - "html/template" "log" "net/http" "strconv" @@ -31,43 +31,10 @@ type modelController interface { SwapModel(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error) } -var modelsTmpl = template.Must(template.New("models").Funcs(shellFuncs()).Parse(shellHTML + modelsHTML)) +//go:embed models.html +var modelsHTML string -const modelsHTML = `{{template "shellTop" "models"}} -

Resident model

-

swapping requires step-up — assert a passkey first. The old model is unloaded before the new one is loaded (one model fits the iGPU at a time), so turns during the load are refused and fall back to the classifier.

-

a swap is not remembered. Nothing writes it down, so the next restart of the daemon — including the one mavupdate does — comes back on phraser.model_path from the config. Make it stick by editing that.

-{{if .Msg}}
{{.Msg}}
{{end}} -{{if .Err}}
{{.Err}}
{{end}} -{{if .Off}} -
-

swap not configured

-

this core has no phraser.swap_models allowlist, so there is nothing to swap to. Add the gguf paths you allow to deploy/mavend.json and restart once.

-
-{{else}} -
-

loaded now

-
- - - - - -
model{{.Status.Model}}
file{{.Status.ModelPath}}
server{{.Status.BaseURL}}
n_ctx{{.Status.NCtx}}
n_gpu_layers{{.Status.NGpuLayers}}
-

the model name is what llama-server reports for itself, not what the config says it should be.

-
-
-

allowed models {{len .Status.Swappable}}

-{{if .Status.Swappable}}
-{{range .Status.Swappable}} -{{end}} -
file
{{.}}
- -
-{{else}}
no models allowlisted
{{end}} -
-{{end}} -{{template "shellBottom"}}` +var modelsTmpl = parsePage("models", modelsHTML, nil) type modelsPage struct { Msg string @@ -154,8 +121,5 @@ func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, swap } } page.Status = st - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := modelsTmpl.Execute(w, page); err != nil { - log.Printf("models render: %v", err) - } + renderPage(w, modelsTmpl, page) } diff --git a/cmd/mavweb/models.html b/cmd/mavweb/models.html new file mode 100644 index 0000000..0990b54 --- /dev/null +++ b/cmd/mavweb/models.html @@ -0,0 +1,35 @@ +{{template "shellTop" "models"}} +

Resident model

+

swapping requires step-up — assert a passkey first. The old model is unloaded before the new one is loaded (one model fits the iGPU at a time), so turns during the load are refused and fall back to the classifier.

+

a swap is not remembered. Nothing writes it down, so the next restart of the daemon — including the one mavupdate does — comes back on phraser.model_path from the config. Make it stick by editing that.

+{{if .Msg}}
{{.Msg}}
{{end}} +{{if .Err}}
{{.Err}}
{{end}} +{{if .Off}} +
+

swap not configured

+

this core has no phraser.swap_models allowlist, so there is nothing to swap to. Add the gguf paths you allow to deploy/mavend.json and restart once.

+
+{{else}} +
+

loaded now

+
+ + + + + +
model{{.Status.Model}}
file{{.Status.ModelPath}}
server{{.Status.BaseURL}}
n_ctx{{.Status.NCtx}}
n_gpu_layers{{.Status.NGpuLayers}}
+

the model name is what llama-server reports for itself, not what the config says it should be.

+
+
+

allowed models {{len .Status.Swappable}}

+{{if .Status.Swappable}}
+{{range .Status.Swappable}} +{{end}} +
file
{{.}}
+ +
+{{else}}
no models allowlisted
{{end}} +
+{{end}} +{{template "shellBottom"}} diff --git a/cmd/mavweb/notifications.go b/cmd/mavweb/notifications.go new file mode 100644 index 0000000..97d68e5 --- /dev/null +++ b/cmd/mavweb/notifications.go @@ -0,0 +1,76 @@ +package main + +import ( + _ "embed" + "log" + "net/http" + "strconv" + + "github.com/kami/maven/internal/ipc" +) + +//go:embed notifications.html +var notificationsHTML string + +var notificationsTmpl = parsePage("notifications", notificationsHTML, nil) + +// deliveryRow is one outbox line, with every timestamp already formatted so +// the template holds no date logic — same shape as taskRow. +type deliveryRow struct { + Kind string + Target string + Channel string + Status string + Created string + Completed string +} + +func deliveryRows(as []ipc.DeliveryAttempt) []deliveryRow { + out := make([]deliveryRow, 0, len(as)) + for _, a := range as { + target := a.Rule + if target == "" && a.ReminderID != 0 { + target = "reminder #" + strconv.FormatInt(a.ReminderID, 10) + } + row := deliveryRow{ + Kind: a.Kind, + Target: target, + Channel: a.Channel, + Status: a.Status, + Created: a.Created.Format("02.01 15:04"), + } + if a.Completed != nil { + row.Completed = a.Completed.Format("15:04") + } + out = append(out, row) + } + return out +} + +func handleNotifications(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { + if !requireCore(w, core, "notifications") { + 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 + } + // The outbox, on the page that already answers "what did she send". + // A failed or dropped attempt is why she went quiet, and until now it was + // recorded and unreadable (Vikunja #390). Filter with ?status=dropped. + status := r.URL.Query().Get("status") + attempts, err := core.DeliveryAttempts(ctx, status, 50) + if err != nil { + // The nudge list is still worth showing, so this is a note on the page + // rather than a dead page. + log.Printf("notifications: delivery attempts: %v", err) + } + renderPage(w, notificationsTmpl, map[string]any{ + "Nudges": nudges, + "Attempts": deliveryRows(attempts), + "Status": status, + }) +} diff --git a/cmd/mavweb/pages.go b/cmd/mavweb/pages.go new file mode 100644 index 0000000..1fa5688 --- /dev/null +++ b/cmd/mavweb/pages.go @@ -0,0 +1,205 @@ +package main + +import ( + "cmp" + _ "embed" + "html/template" + "log" + "net/http" + "strings" + "time" + + "github.com/kami/maven/internal/ipc" +) + +// The read-only pages: dash, history, trace, morning, events, and the voice +// page mounted at "/". Each is GET-only, reads through CoreAPI and renders. +// The write surfaces live next to their own handlers (tasks.go, tools.go, +// routines.go, chat.go). + +//go:embed dash.html +var dashHTML string + +//go:embed history.html +var historyHTML string + +//go:embed trace.html +var traceHTML string + +//go:embed morning.html +var morningHTML string + +//go:embed events.html +var eventsHTML string + +//go:embed voice.html +var voiceHTML string + +//go:embed ecosystem.html +var ecosystemHTML string + +// 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 = parsePage("dash", dashHTML, nil) + +var historyTmpl = parsePage("history", historyHTML, nil) + +var traceTmpl = parsePage("trace", traceHTML, template.FuncMap{ + "fmtTime": func(t *time.Time) string { + if t == nil || t.IsZero() { + return "—" + } + return t.Format("15:04:05") + }, + "join": strings.Join, +}) + +// morningTmpl — read-only view of today's checklist state per configured +// morning routine (internal/morning). Same shape as trace.html: a plain +// server-rendered page, refreshed on reload — no live-update loop, since +// checklist state changes on the scale of minutes, not seconds. +var morningTmpl = parsePage("morning", morningHTML, nil) + +// eventsTmpl — the unified intake journal (Vikunja #283), read-only. Same +// shape as trace.html and morning.html: server-rendered, refreshed on reload. +var eventsTmpl = parsePage("events", eventsHTML, nil) + +var voiceTmpl = parsePage("voice", voiceHTML, nil) + +// 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 = parsePage("ecosystem", ecosystemHTML, nil) + +func handleDash(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { + if !requireCore(w, core, "dash") { + 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 + } + renderPage(w, dashTmpl, struct { + Presence ipc.Presence + Facts []ipc.Fact + Nudges []ipc.Nudge + Notes []ipc.Note + }{pres, facts, nudges, notes}) +} + +func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { + if !requireCore(w, core, "history") { + return + } + facts, err := core.RecentFacts(r.Context(), 200) + if err != nil { + log.Printf("history: %v", err) + http.Error(w, "core read failed", http.StatusBadGateway) + return + } + renderPage(w, historyTmpl, struct { + Facts []ipc.Fact + }{facts}) +} + +func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { + if !requireCore(w, core, "trace") { + return + } + trace, err := core.TickTrace(r.Context()) + if err != nil { + log.Printf("trace: %v", err) + http.Error(w, "core read failed", http.StatusBadGateway) + return + } + // The turn records share this page rather than getting one of their own + // (V-564). Both answer the same question, who won and who lost and why, and + // one is about nudges while the other is about utterances. A read failure + // here is not fatal to the page. The rule trace above it still renders, and + // a daemon too old to know the method is the ordinary case during a rolling + // deploy. + turns, err := core.TurnDecisions(r.Context(), 25) + if err != nil { + log.Printf("trace: turn decisions: %v", err) + } + renderPage(w, traceTmpl, traceData{Tick: trace, Turns: turns}) +} + +// traceData — what trace.html renders: the last tick's rule arbitration and the +// last turns' claim arbitration. +type traceData struct { + Tick ipc.TickTrace + Turns []ipc.TurnDecision +} + +// morningView — what /morning renders: today's plan on top, the checklist +// state under it. PlanErr is set instead of Plan when the core could not build +// a plan, so the page says so rather than showing an empty day. +type morningView struct { + Plan *ipc.DayPlan + PlanErr string + Routines []ipc.MorningRoutineStatus +} + +func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { + if !requireCore(w, core, "morning") { + return + } + ctx := r.Context() + status, err := core.MorningStatus(ctx) + if err != nil { + log.Printf("morning: %v", err) + http.Error(w, "core read failed", http.StatusBadGateway) + return + } + view := morningView{Routines: status} + // The day plan (#128) shows on this page because it is the same question at + // a different scale. A plan read that fails must not take the checklist + // down with it — the page degrades to what it had before. + plan, err := core.DayPlan(ctx) + if err != nil { + log.Printf("morning: day plan: %v", err) + view.PlanErr = err.Error() + } else { + view.Plan = &plan + } + renderPage(w, morningTmpl, view) +} + +// eventsView — what /events renders. Err is set instead of Events when the +// core could not serve the journal, so the page says why rather than showing an +// empty intake and implying nothing arrived. +type eventsView struct { + Events []ipc.IntakeEvent + Err string +} + +// eventsPageLimit — how many envelopes the page shows. The ring holds more; a +// page is for scanning what just happened, not for archaeology. +const eventsPageLimit = 200 + +func handleEvents(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { + if !requireCore(w, core, "intake journal") { + return + } + var view eventsView + evs, err := core.RecentEvents(r.Context(), eventsPageLimit) + if err != nil { + log.Printf("events: %v", err) + view.Err = err.Error() + } else { + view.Events = evs + } + renderPage(w, eventsTmpl, view) +} + +func handleVoice(w http.ResponseWriter, r *http.Request) { + renderPage(w, voiceTmpl, nil) +} diff --git a/cmd/mavweb/passkey.html b/cmd/mavweb/passkey.html new file mode 100644 index 0000000..6ea67e0 --- /dev/null +++ b/cmd/mavweb/passkey.html @@ -0,0 +1,58 @@ +{{template "shellTop" "passkey"}} +

Passkey

+

Enroll a passkey once, then assert it to unlock destructive actions (tool enable) for a few minutes.

+
+ + + + +
+

Rewriting the cold-start key points it at the passkey you assert next. Every other enrolled passkey stops being able to unlock a cold-booted daemon.

+
+{{template "shellBottom"}} + diff --git a/cmd/mavweb/reminders.go b/cmd/mavweb/reminders.go new file mode 100644 index 0000000..b953ac6 --- /dev/null +++ b/cmd/mavweb/reminders.go @@ -0,0 +1,74 @@ +package main + +import ( + _ "embed" + "encoding/json" + "log" + "net/http" + "strings" + + "github.com/kami/maven/internal/ipc" +) + +//go:embed reminders.html +var remindersHTML string + +var remindersTmpl = parsePage("reminders", remindersHTML, nil) + +// reminderRow is one line on /reminders, with the payload unwrapped and both +// timestamps already in his clock. +// +// The page rendered `{{.Payload}}` and the UTC instant, so a reminder read +// `{"text":"выпить таблетки"}` and fired an hour off what he was told +// (Vikunja #469). Neither is a formatting nicety: the envelope is an internal +// shape he never chose, and a time on a page he reads is the time on his wall. +type reminderRow struct { + Created string + Fires string + Status string + Text string +} + +// reminderText unwraps the {"text":...} payload the router writes. +// +// A copy of store.ReminderText rather than a call to it, because mavweb is one +// of the pure-Go daemons and internal/store carries the CGO sqlite driver. The +// ipc DTO is decoupled from the store on purpose, so the unwrap belongs to +// whoever renders it. Payload that is not that shape is shown as he said it. +func reminderText(payload string) string { + var m map[string]any + if err := json.Unmarshal([]byte(payload), &m); err == nil { + if t, ok := m["text"]; ok { + if s, isStr := t.(string); isStr && s != "" { + return s + } + } + } + return strings.TrimSpace(payload) +} + +func reminderRows(rs []ipc.Reminder) []reminderRow { + out := make([]reminderRow, 0, len(rs)) + for _, r := range rs { + out = append(out, reminderRow{ + Created: r.CreatedTs.Local().Format("02 Jan 15:04"), + Fires: r.FireTs.Local().Format("02 Jan 15:04"), + Status: r.Status, + Text: reminderText(r.Payload), + }) + } + return out +} + +func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { + if !requireCore(w, core, "reminders") { + return + } + reminders, err := core.ListReminders(r.Context(), 50) + if err != nil { + log.Printf("reminders: %v", err) + http.Error(w, "reminders error: "+err.Error(), http.StatusBadGateway) + return + } + renderPage(w, remindersTmpl, map[string]any{"Reminders": reminderRows(reminders)}) +} diff --git a/cmd/mavweb/routines.go b/cmd/mavweb/routines.go new file mode 100644 index 0000000..1ca6db3 --- /dev/null +++ b/cmd/mavweb/routines.go @@ -0,0 +1,200 @@ +package main + +import ( + "context" + _ "embed" + "errors" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/pattern" + "github.com/kami/maven/internal/webauthn" +) + +//go:embed routines.html +var routinesHTML string + +// routinesTmpl — the proposed-routine review surface. One row per thing maven +// noticed, in her words, with at most two actions: accept or dismiss. +var routinesTmpl = parsePage("routines", routinesHTML, nil) + +// routineView is one line on the page: what maven noticed, in her words, and +// how long ago she noticed it. A view model, not a database row — the template +// never formats an interval or a timestamp itself. +type routineView struct { + ID int64 + Phrase string + Noticed string +} + +// handleRoutines serves the routine review surface (GET) and answers a +// proposal (POST id + action=accept|dismiss). +// +// Accept is gated at step-up, the same tier as enabling a tool: saying yes +// hands the trigger loop a new standing reason to speak to the human, so it +// moves the boundary and only an authed surface may do it. Dismiss is not +// gated — it only ever removes a reason to speak, so the worst a weaker caller +// can do is make maven quieter. +func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) { + if !requireCore(w, core, "routines") { + return + } + ctx := r.Context() + var msg string + if r.Method == http.MethodPost { + var ok bool + if msg, ok = applyRoutinePost(w, r, core, session, requireStepUp); !ok { + 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 + } + renderPage(w, routinesTmpl, struct { + Msg string + Proposed []routineView + }{msg, toRoutineViews(proposed)}) +} + +// applyRoutinePost performs one write and returns the message to show. Unlike +// the task form, a bad request here is an HTTP status rather than an inline +// note, so the second return says whether the response was already written. +func applyRoutinePost(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) (string, bool) { + ctx := r.Context() + action := r.FormValue("action") + // "seed" is the one action with no routine to act on — it is what + // MAKES a routine (Vikunja #518), so it runs before the id parse. It + // lives on this route rather than a page of its own because it is + // already the step-up-gated surface for this table, and a second gated + // surface is a second thing to get wrong. + if action == "seed" { + if !stepUpGate(w, session, requireStepUp) { + return "", false + } + out, err := seedRoutineEvent(ctx, core, r) + if err != nil { + log.Printf("routines: seed: %v", err) + http.Error(w, "seed failed: "+err.Error(), http.StatusBadGateway) + return "", false + } + return out, true + } + + idStr := r.FormValue("id") + var rid int64 + if n, _ := fmt.Sscanf(idStr, "%d", &rid); n != 1 { + http.Error(w, "invalid id", http.StatusBadRequest) + return "", false + } + switch action { + case "accept": + if !stepUpGate(w, session, requireStepUp) { + return "", false + } + if err := acceptRoutine(ctx, core, rid); err != nil { + log.Printf("routines: accept %d: %v", rid, err) + http.Error(w, "accept failed: "+err.Error(), http.StatusBadGateway) + return "", false + } + return "accepted routine — maven will remind you", true + 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 "", false + } + return "dismissed routine", true + default: + http.Error(w, "unknown action", http.StatusBadRequest) + return "", false + } +} + +// toRoutineViews turns the wire rows into view models. The phrase comes from +// pattern.PhraseRoutine so the page says the same thing maven's voice says. +func toRoutineViews(rs []ipc.ProposedRoutine) []routineView { + out := make([]routineView, 0, len(rs)) + for _, r := range rs { + p := pattern.ProposedRoutine{Action: r.Action, Object: r.Object, IntervalDays: r.IntervalDays} + noticed := "just now" + if r.CreatedTs > 0 { + noticed = time.Since(time.UnixMilli(r.CreatedTs)).Round(time.Minute).String() + " ago" + } + out = append(out, routineView{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed}) + } + return out +} + +// acceptRoutine marks a proposal accepted. This page is the ONLY surface that +// may do it (Vikunja #367): accepting gives the tick loop a standing new +// reason to speak, which DESIGN.md puts at layer 3, and the button here is +// behind step-up. Voice can park the question and dismiss, never accept. +func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error { + proposed, err := core.ListProposedRoutines(ctx) + if err != nil { + return err + } + var found *ipc.ProposedRoutine + for i := range proposed { + if proposed[i].ID == id { + found = &proposed[i] + break + } + } + if found == nil { + return errors.New("no such proposed routine") + } + + // No reminder is created here. Accepting only flips the status; the tick + // loop reads accepted routines and nudges on the interval (Vikunja #366). + // The old code made a one-shot reminder, so a non-weekly routine fired + // once and then went quiet forever. + return core.AcceptProposedRoutine(ctx, id) +} + +// seedRoutineEvent drives one backdated fact write through core (Vikunja #518), +// so the pattern detector can be exercised against a running daemon instead of +// over real days. Refused unless mavend was started with -allow-seed; on an +// ordinary box the error says so and nothing is written. +// +// Takes "ago" rather than an absolute timestamp — hours before now, as a float +// so a QA sitting can space four seeds three hours apart without doing clock +// arithmetic. The detector's floor is two hours, and "0" is a legal answer +// meaning now. +func seedRoutineEvent(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) { + key := strings.TrimSpace(r.FormValue("key")) + value := strings.TrimSpace(r.FormValue("value")) + if key == "" || value == "" { + return "", errors.New("seed needs a key and a value") + } + agoHours, err := strconv.ParseFloat(strings.TrimSpace(r.FormValue("ago")), 64) + if err != nil { + return "", fmt.Errorf("seed: bad ago (hours before now): %w", err) + } + if agoHours < 0 { + return "", errors.New("seed: ago is hours BEFORE now, so it cannot be negative") + } + resp, err := core.SeedEvent(ctx, ipc.SeedEventReq{ + Key: key, + Value: value, + Ts: time.Now().Add(-time.Duration(agoHours * float64(time.Hour))), + }) + if err != nil { + return "", err + } + if !resp.Extracted { + return fmt.Sprintf("wrote fact %d, but %q is not in the action lexicon — no event, no pattern", resp.FactID, value), nil + } + if !resp.Proposed { + return fmt.Sprintf("seeded %s/%s (fact %d, event %d) — not enough yet to propose", resp.Action, resp.Object, resp.FactID, resp.EventID), nil + } + return fmt.Sprintf("seeded %s/%s and PROPOSED routine %d, every %.1f days", resp.Action, resp.Object, resp.RoutineID, resp.IntervalDays), nil +} diff --git a/cmd/mavweb/shell.go b/cmd/mavweb/shell.go new file mode 100644 index 0000000..faa9002 --- /dev/null +++ b/cmd/mavweb/shell.go @@ -0,0 +1,194 @@ +package main + +import ( + _ "embed" + "html/template" + "log" + "net/http" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/webauthn" +) + +// shellHTML — the shell partial every page is wrapped in: "shellTop", the +// "sidebar" it calls, and "shellBottom". It used to be two Go string constants +// with the sidebar assembled by a strings.Builder, which is the one piece of +// markup that was still concatenated in Go. +// +// Two template pieces 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. +// +//go:embed shell.html +var shellHTML string + +// 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: "Tasks", URL: "/tasks", Key: "tasks"}, + {Label: "Reminders", URL: "/reminders", Key: "reminders"}, + {Label: "Routines", URL: "/routines", Key: "routines"}, + {Label: "Morning", URL: "/morning", Key: "morning"}, + {Label: "Intake", URL: "/events", Key: "events"}, + }, + }, + { + 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: "Model", URL: "/models", Key: "models"}, + {Label: "Passkey", URL: "/auth/passkey", Key: "passkey"}, + }, + }, +} + +// pageChrome is the per-page title and ethos-icons.svg symbol id, keyed by the +// page key a page hands to shellTop. One table rather than two parallel +// switches, so a new page cannot end up with a title and no icon. +var pageChrome = map[string]struct{ Title, Icon string }{ + "dash": {"Dashboard", "i-grid"}, + "history": {"History", "i-clock"}, + "trace": {"Rule Trace", "i-wave"}, + "notifications": {"Notifications", "i-bell"}, + "tasks": {"Tasks", "i-grid"}, + "reminders": {"Reminders", "i-calendar"}, + "routines": {"Routines", "i-repeat"}, + "morning": {"Morning Routines", "i-calendar"}, + "chat": {"Chat", "i-message"}, + "voice": {"Voice", "i-mic"}, + "ecosystem": {"Ecosystem", "i-grid"}, + "tools": {"Tools", "i-settings"}, + "models": {"Resident Model", "i-wave"}, + "passkey": {"Passkey", "i-lock"}, +} + +// pageIcon returns the ethos-icons.svg symbol id for the given page. The +// sidebar template wraps it in the reference. +func pageIcon(key string) string { + if c, ok := pageChrome[key]; ok { + return c.Icon + } + return "i-search" +} + +// pageTitle returns the human-readable page title for the given key. An +// unknown key renders as itself rather than as a blank crumb. +func pageTitle(key string) string { + if c, ok := pageChrome[key]; ok { + return c.Title + } + return key +} + +// shellFuncs returns the FuncMap shared by every server-rendered page template. +func shellFuncs() template.FuncMap { + return template.FuncMap{ + "pageTitle": pageTitle, + "pageIcon": pageIcon, + "sidebarSections": func() any { return sidebarSections }, + "ago": func(t time.Time) string { + if t.IsZero() { + return "never" + } + return time.Since(t).Round(time.Second).String() + " ago" + }, + } +} + +// parsePage parses one server-rendered page: the shell partial plus the page's +// own embedded markup, under the shared FuncMap. extra adds page-local +// functions (/tools needs capability lookups, /trace a time format) and may be +// nil. +// +// The name is also the page's log label, so a render failure says which page. +func parsePage(name, body string, extra template.FuncMap) *template.Template { + funcs := shellFuncs() + for k, v := range extra { + funcs[k] = v + } + return template.Must(template.New(name).Funcs(funcs).Parse(shellHTML + body)) +} + +// renderPage writes one page. Every handler sent the same content type and +// logged the same way on failure; the header is already written by then, so a +// render error can only be logged, never reported. +func renderPage(w http.ResponseWriter, t *template.Template, data any) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := t.Execute(w, data); err != nil { + log.Printf("%s render: %v", t.Name(), err) + } +} + +// requireCore answers whether the surface has a core to read. mavweb runs +// without -core (voice-only), and every page that needs mavend says so with a +// 503 naming itself rather than a blank error. +func requireCore(w http.ResponseWriter, core ipc.CoreAPI, surface string) bool { + if core == nil { + http.Error(w, surface+" disabled (no -core)", http.StatusServiceUnavailable) + return false + } + return true +} + +// stepUpGate reports whether the caller may proceed through the AuthStepUp +// gate, writing the 403 itself when it may not. See stepUpOK for the policy. +func stepUpGate(w http.ResponseWriter, session *webauthn.PasskeySession, requireStepUp bool) bool { + if stepUpOK(session, requireStepUp) { + return true + } + http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden) + return false +} + +// 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() +} diff --git a/cmd/mavweb/tasks.go b/cmd/mavweb/tasks.go new file mode 100644 index 0000000..2a860ee --- /dev/null +++ b/cmd/mavweb/tasks.go @@ -0,0 +1,408 @@ +package main + +import ( + "context" + _ "embed" + "errors" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/tasks" +) + +//go:embed tasks.html +var tasksHTML string + +var tasksTmpl = parsePage("tasks", tasksHTML, nil) + +// now — the wall clock, indirected so the task page can be rendered at a fixed +// instant in a test. internal/tasks is pure and the daemon path already ranks +// through a clock it is handed; the page had no reason to be the one surface +// that could only be tested at whatever time it happened to run. +var now = time.Now + +// resolvedShown — how many finished tasks the page renders. The list is +// history, it only grows, and the rows below the first screen are read by +// nobody. +const resolvedShown = 50 + +// taskRow is one line on /tasks, with every timestamp already formatted so the +// template holds no date logic. +type taskRow struct { + ID int64 + Text string + Source string + Evidence string + Status string + Due string + Created string + Resolved string + ResolvedBy string + // DueValue and Weight are the raw values the edit form posts back + // (Vikunja #509). Due above is for reading and says "—" for no date; a + // date input needs "2026-08-07" or the empty string. + DueValue string + Weight int + // Why — the ranker's reason for this row's position (Vikunja #129), in + // Russian, empty when nothing distinguished the task. Blank is the honest + // rendering: he never said this one mattered more. + Why string +} + +// rowOf renders one wire task into the shared read-only columns. The two call +// sites below add what only they need: the live rows carry the edit form's raw +// values and the ranker's reason, the resolved rows carry neither. +func rowOf(t ipc.Task) taskRow { + return taskRow{ + ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence, + Status: t.Status, Created: fmtTaskTime(&t.CreatedTs), + Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved), + ResolvedBy: t.ResolvedBy, + } +} + +// handleTasks serves the task review surface (GET) and the five writes it +// offers (POST): add, edit, confirm, done, drop. +// +// Not step-up gated, unlike /tools and /routines, and the difference is the +// point: enabling a tool defines argv Maven will execute, and accepting a +// routine hands the tick loop a new standing reason to interrupt him. A task is +// neither — nothing in the tick loop reads the tasks table, so the worst a +// weaker caller can do here is write a line onto a list he reads himself. It +// still sits behind whatever transport auth fronts mavweb, like every other +// page. +// +// "edit" was re-argued on the same terms rather than inheriting the exemption +// (Vikunja #509), and it stays ungated. It rewrites a line on a list he reads +// himself, the same blast radius "drop" already has on this page, and the store +// refuses the two edits that would cost something: a resolved task keeps the +// text it was finished under, and a text collision with another live row is +// named instead of merged. +// +// "confirm" is the only interesting move: it promotes a candidate Maven derived +// from something she read into work he owns. That review step is why derived +// tasks are captured as candidates in the first place. +func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { + if !requireCore(w, core, "tasks") { + return + } + ctx := r.Context() + var msg, errMsg string + if r.Method == http.MethodPost { + var err error + msg, err = applyTaskPost(ctx, core, r) + if err != nil { + log.Printf("tasks: %v", err) + errMsg = err.Error() + } + } + + all, err := core.ListTasks(ctx, "") + if err != nil { + log.Printf("tasks: list: %v", err) + http.Error(w, "tasks error: "+err.Error(), http.StatusBadGateway) + return + } + // Live rows are ordered by the same ranker the spoken list uses, so the page + // and the voice reply can never disagree about what comes first. Resolved + // rows keep store order (newest first) — ranking finished work is pointless. + var live []tasks.Item + var resolved []taskRow + resolvedTotal := 0 + for _, t := range all { + switch t.Status { + case "candidate", "open": + live = append(live, tasks.Item{ + ID: t.ID, Text: t.Text, Status: t.Status, + Created: t.CreatedTs, Due: t.Due, Weight: t.Weight, + }) + default: + resolvedTotal++ + // Finished work is history, and the history only grows. The page + // showed every row that ever existed, which is a page that gets + // slower every month for a section nobody reads past the top of. + if len(resolved) >= resolvedShown { + continue + } + resolved = append(resolved, rowOf(t)) + } + } + byID := make(map[int64]ipc.Task, len(all)) + for _, t := range all { + byID[t.ID] = t + } + var cands, open []taskRow + for _, r := range tasks.Rank(live, now()) { + t := byID[r.ID] + row := rowOf(t) + row.DueValue = fmtTaskDateValue(t.Due) + row.Weight = t.Weight + row.Why = r.Reason + if t.Status == "candidate" { + // A candidate's due date is Maven's reading of a mail, so its + // ranking reason is not shown as if he had set a priority. + row.Why = "" + cands = append(cands, row) + } else { + open = append(open, row) + } + } + renderPage(w, tasksTmpl, struct { + Msg, Err string + Stalls []tasks.Stall + Candidates []taskRow + Open []taskRow + Resolved []taskRow + ResolvedMore bool + }{msg, errMsg, tasks.Stalls(live, now()), cands, open, resolved, resolvedTotal > len(resolved)}) +} + +// applyTaskPost performs one write and returns the message to show. A bad +// request returns an error, which the page renders inline rather than as a +// bare 400 — this is a form surface, not an API. +func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) { + action := r.FormValue("action") + if action == "add" { + text := strings.TrimSpace(r.FormValue("text")) + if text == "" { + return "", errors.New("empty task text") + } + req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: now()} + wgt, err := formWeight(r) + if err != nil { + return "", err + } + req.Weight = wgt + due, err := formDue(r, now()) + if err != nil { + return "", err + } + req.Due = due + resp, err := core.CaptureTask(ctx, req) + if err != nil { + return "", err + } + if resp.Promoted { + return "confirmed a candidate maven had found", nil + } + if !resp.Created { + return "already on the list", nil + } + return "added task", nil + } + + id, err := strconv.ParseInt(r.FormValue("id"), 10, 64) + if err != nil { + return "", errors.New("invalid id") + } + + if action == "promote" { + msg, err := promoteCandidate(ctx, core, r, id) + if err != nil { + return "", err + } + return msg, nil + } + + if action == "edit" { + // The three fields capture set, and only those (Vikunja #509). Status + // is not editable here: that ladder is one-way and has its own buttons. + text := strings.TrimSpace(r.FormValue("text")) + if text == "" { + return "", errors.New("empty task text") + } + wgt, err := formWeight(r) + if err != nil { + return "", err + } + due, err := formDue(r, now()) + if err != nil { + return "", err + } + switch err := core.EditTask(ctx, id, text, due, wgt); { + case err == nil: + return "saved task", nil + case errors.Is(err, ipc.ErrTaskDuplicate): + // Naming the collision instead of merging: two live rows carry two + // provenances, and picking one is not the page's call. + return "", errors.New("another open task already says this — drop one of the two") + case errors.Is(err, ipc.ErrTaskResolved): + return "", errors.New("a resolved task keeps the text it was finished under") + default: + return "", err + } + } + + var status, msg string + switch action { + case "confirm": + status, msg = "open", "confirmed task" + case "done": + status, msg = "done", "task done" + case "drop": + status, msg = "dropped", "dropped task" + default: + return "", fmt.Errorf("unknown action %q", action) + } + if err := core.SetTaskStatus(ctx, id, status, now(), "tap:web"); err != nil { + return "", statusWriteErr(err) + } + return msg, nil +} + +// errNoDoneWhen — the refusal has to name what is missing, or the button looks +// broken. The field it asks for arrives with the intake form (Vikunja #511). +var errNoDoneWhen = errors.New("write a definition of done before confirming this candidate") + +// statusWriteErr translates a SetTaskStatus failure into what the page says. +func statusWriteErr(err error) error { + if errors.Is(err, ipc.ErrTaskNoDoneWhen) { + return errNoDoneWhen + } + return err +} + +// promoteCandidate turns a candidate into open work with the three things the +// board needs (Vikunja #511): a definition of done, an optional blocker, and an +// optional date. +// +// The definition of done is required, and the refusal is the store's — this +// only reaches it in a readable order. The blocker is a NAME here and an entity +// id in the row: identity lives in Nexus, so the name is resolved first and a +// name Nexus cannot resolve stops the promotion instead of being stored. +// +// A date set here writes a reminder, which is the one unprompted delivery the +// persona allows: he asked to be told, on a day he named. +func promoteCandidate(ctx context.Context, core ipc.CoreAPI, r *http.Request, id int64) (string, error) { + doneWhen := strings.TrimSpace(r.FormValue("done_when")) + if doneWhen == "" { + return "", errors.New("write a definition of done — what has to be true for this to be finished") + } + text := strings.TrimSpace(r.FormValue("text")) + if text == "" { + return "", errors.New("empty task text") + } + due, err := formDue(r, now()) + if err != nil { + return "", err + } + + blockedOn, err := resolveBlocker(ctx, core, r.FormValue("blocked_on")) + if err != nil { + return "", err + } + + if err := core.SetTaskFields(ctx, id, doneWhen, blockedOn); err != nil { + return "", err + } + if due != nil { + wgt, err := formWeight(r) + if err != nil { + return "", err + } + if err := core.EditTask(ctx, id, text, due, wgt); err != nil { + return "", err + } + } + if err := core.SetTaskStatus(ctx, id, "open", now(), "tap:web"); err != nil { + return "", statusWriteErr(err) + } + if due == nil { + return "confirmed", nil + } + // A date-only field has no hour. Nine in the morning, because the reminder + // is about a day's work and being told at midnight is being told the night + // before. + fire := time.Date(due.Year(), due.Month(), due.Day(), 9, 0, 0, 0, due.Location()) + if _, err := core.CreateReminder(ctx, fire, text, ""); err != nil { + // The task IS promoted; only the reminder failed. Saying "confirmed" + // and nothing else would leave him expecting a nudge that will not come. + return "", fmt.Errorf("confirmed, but the reminder did not save: %w", err) + } + return "confirmed, and maven will remind you that morning", nil +} + +// resolveBlocker turns the blocked-on NAME the form posts into the entity id +// the row stores. Identity lives in Nexus, so an unresolvable name stops the +// promotion instead of being written as free text. An empty field is no +// blocker and reaches Nexus not at all. +func resolveBlocker(ctx context.Context, core ipc.CoreAPI, field string) (string, error) { + name := strings.TrimSpace(field) + if name == "" { + return "", nil + } + ref, err := core.ResolveEntity(ctx, name, []string{"person"}) + switch { + case errors.Is(err, ipc.ErrNotImplemented): + return "", errors.New("no identity service here, so blocked-on cannot be stored — leave it empty") + case errors.Is(err, ipc.ErrNoEntity): + return "", fmt.Errorf("nexus does not know %q", name) + case err != nil: + return "", fmt.Errorf("resolving %q: %w", name, err) + case ref.Ambiguous: + // Asking, not picking: a task blocked on the wrong person is a + // mistake nobody can see afterwards. + return "", fmt.Errorf("%q matches %s — say which", name, strings.Join(ref.Candidates, ", ")) + } + return ref.ID, nil +} + +// formWeight reads the importance select. Out-of-range clamps rather than +// rejects — a bad select is not worth a 400 — but trailing garbage is refused, +// because strconv is not Sscanf and "3junk" is not a 3. +func formWeight(r *http.Request) (int, error) { + v := r.FormValue("weight") + if v == "" { + return 0, nil + } + wgt, err := strconv.Atoi(v) + if err != nil || wgt < 0 { + return 0, fmt.Errorf("bad weight %q", v) + } + if wgt > tasks.MaxWeight { + wgt = tasks.MaxWeight + } + return wgt, nil +} + +// formDue reads the date input. An empty field is nil, which on an edit means +// "clear the date" — the form has no other way to say it. +func formDue(r *http.Request, now time.Time) (*time.Time, error) { + d := r.FormValue("due") + if d == "" { + return nil, nil + } + due, err := time.ParseInLocation("2006-01-02", d, now.Location()) + if err != nil { + return nil, fmt.Errorf("bad due date %q", d) + } + return &due, nil +} + +// fmtTaskDateValue renders a due date the way requires, or +// "" for no date. Separate from fmtTaskDate, which renders it for reading. +func fmtTaskDateValue(t *time.Time) string { + if t == nil || t.IsZero() { + return "" + } + return t.Local().Format("2006-01-02") +} + +func fmtTaskTime(t *time.Time) string { + if t == nil || t.IsZero() { + return "—" + } + return t.Local().Format("02 Jan 15:04") +} + +func fmtTaskDate(t *time.Time) string { + if t == nil || t.IsZero() { + return "—" + } + return t.Local().Format("02 Jan") +} diff --git a/cmd/mavweb/tools.go b/cmd/mavweb/tools.go new file mode 100644 index 0000000..5a445e9 --- /dev/null +++ b/cmd/mavweb/tools.go @@ -0,0 +1,112 @@ +package main + +import ( + "cmp" + _ "embed" + "html/template" + "log" + "net/http" + "strings" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/tool" + "github.com/kami/maven/internal/webauthn" +) + +//go:embed tools.html +var toolsHTML string + +// 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 = parsePage("tools", toolsHTML, template.FuncMap{ + "join": strings.Join, + "capability": func(t ipc.Tool) string { return tool.CapabilityOf(t).String() }, + "risk": func(t ipc.Tool) string { return string(tool.RiskOf(t)) }, +}) + +// 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 !requireCore(w, core, "tools") { + return + } + ctx := r.Context() + var msg string + if r.Method == http.MethodPost { + if !stepUpGate(w, session, requireStepUp) { + 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 + } + // MCP is off by default and an older core may not know the method at all, + // so a failure here renders an empty section rather than breaking the page. + servers, err := core.MCPServers(ctx) + if err != nil { + log.Printf("tools: mcp servers: %v", err) + servers = nil + } + // Enabled rows are shown grouped by capability domain (Vikunja #452). A + // flat list stops answering "what can she do to the house" somewhere + // around fifteen rows, and that is the question this page exists for. + renderPage(w, toolsTmpl, struct { + Msg string + Proposed []ipc.Tool + Enabled []ipc.Tool + Groups []tool.CapabilityGroup + MCP []ipc.MCPServerStatus + }{msg, proposed, enabled, tool.GroupByDomain(enabled), servers}) +} diff --git a/cmd/mavweb/voiceproxy.go b/cmd/mavweb/voiceproxy.go new file mode 100644 index 0000000..83b94cb --- /dev/null +++ b/cmd/mavweb/voiceproxy.go @@ -0,0 +1,245 @@ +package main + +import ( + "context" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "net/url" + "time" + + "github.com/coder/websocket" + "github.com/kami/maven/internal/audio" + "github.com/kami/maven/internal/voice" + "github.com/kami/maven/internal/webauthn" +) + +// The two proxies onto mavend's voice port: GET /ws streams turns over a +// websocket, POST /api/ptt does one turn over plain HTTP. Both carry the same +// step-up gate, because speaking an act is not a smaller act than typing one +// (Vikunja #317). The length-prefixed framing they share is at the bottom. + +// maxFrame caps a single voice frame in either direction. +const maxFrame = 64 << 20 + +// pushToTalk builds the one request either proxy sends. Surface is +// SurfacePCClient for both: the browser is standing in for the PC client. +func pushToTalk(pcm []byte) voice.Request { + return voice.Request{ + ID: uint64(time.Now().UnixNano()), + Method: voice.MethodPushToTalk, + Params: mustMarshal(voice.PushToTalkReq{ + Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: pcm}, + Lang: "mixed", + Surface: voice.SurfacePCClient, + }), + } +} + +func handleWS(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) { + if !stepUpGate(w, session, requireStepUp) { + return + } + 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)) + req := pushToTalk(msg) + 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 + } + } +} + +func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) { + if r.Method != http.MethodPost { + http.Error(w, "POST only", 405) + return + } + if !stepUpGate(w, session, requireStepUp) { + 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)) + + 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 := pushToTalk(body) + 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") + // PathEscape, not QueryEscape (Vikunja #533). QueryEscape writes a space + // as "+", which is form encoding, and the client decodes this header + // with decodeURIComponent, which only knows "%20" — so every space in a + // spoken reply reached the on-page log as a plus sign. PathEscape is the + // flavour decodeURIComponent actually reverses, which keeps the encoding + // a property of the header rather than something the client has to know. + w.Header().Set("X-Reply-Text", url.PathEscape(pttResp.ReplyText)) + w.Write(pttResp.ReplyAudio.Bytes) + return + } +} + +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) + } + 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[:]) + 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 mustMarshal(v any) json.RawMessage { + b, err := json.Marshal(v) + if err != nil { + panic(err) + } + return b +} diff --git a/cmd/mavweb/webauthn.go b/cmd/mavweb/webauthn.go index 6717e8c..f1ef4a7 100644 --- a/cmd/mavweb/webauthn.go +++ b/cmd/mavweb/webauthn.go @@ -2,6 +2,7 @@ package main import ( "context" + _ "embed" "encoding/json" "errors" "fmt" @@ -76,69 +77,17 @@ func newPasskeyHandle(cfg webauthn.Config, core ipc.CoreAPI, storePath string, s // on: assert here (bumps the daemon session to L3 for the assertion TTL), then // enable a tool on /tools within that window. func (h *PasskeyHandle) Page(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - passkeyTmpl.Execute(w, nil) + renderPage(w, passkeyTmpl, nil) } -// passkeyPageHTML — rendered via passkeyTmpl (main.go) which wraps with shellTop/shellBottom. -const passkeyPageHTML = `{{template "shellTop" "passkey"}} -

Passkey

-

Enroll a passkey once, then assert it to unlock destructive actions (tool enable) for a few minutes.

-
- - - - -
-

Rewriting the cold-start key points it at the passkey you assert next. Every other enrolled passkey stops being able to unlock a cold-booted daemon.

-
-{{template "shellBottom"}} -` +// passkeyPageHTML — the enrolment page's own markup, wrapped by passkeyTmpl +// with shellTop/shellBottom. It was a Go string constant, which is the one +// place page markup still lived in Go. +// +//go:embed passkey.html +var passkeyPageHTML string + +var passkeyTmpl = parsePage("passkey", passkeyPageHTML, nil) func (h *PasskeyHandle) RegisterBegin(w http.ResponseWriter, r *http.Request) { opts, challenge, err := h.rp.CreationOptions([]byte("maven-user"), "maven user")