Files
Maven/cmd/mavweb/tools.go
T
claude 35c6ff5a71 Make delivery and integration failures explicit
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
2026-08-13 02:50:59 +04:00

118 lines
4.0 KiB
Go

package main
import (
"cmp"
_ "embed"
"fmt"
"html/template"
"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, r, core, "tools") {
return
}
ctx := r.Context()
var msg string
if r.Method == http.MethodPost {
if !stepUpGate(w, r, 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 {
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
"name and cmd required", nil)
return
}
if err := core.EnableTool(ctx, name, cmd, destructive, scope, time.Now()); err != nil {
writeProblem(w, r, http.StatusBadGateway, problemToolsChange,
"enable failed", fmt.Errorf("enable tool %q: %w", name, err))
return
}
msg = "enabled " + name
case "disable":
if name == "" {
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
"name required", nil)
return
}
if err := core.DisableTool(ctx, name); err != nil {
writeProblem(w, r, http.StatusBadGateway, problemToolsChange,
"disable failed", fmt.Errorf("disable tool %q: %w", name, err))
return
}
msg = "disabled " + name
case "dismiss":
if name == "" {
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
"name required", nil)
return
}
if err := core.DeleteTool(ctx, name); err != nil {
writeProblem(w, r, http.StatusBadGateway, problemToolsChange,
"dismiss failed", fmt.Errorf("dismiss tool %q: %w", name, err))
return
}
msg = "dismissed " + name
default:
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
"unknown action", nil)
return
}
}
proposed, err1 := core.ListTools(ctx, "proposed")
enabled, err2 := core.ListTools(ctx, "enabled")
if err := cmp.Or(err1, err2); err != nil {
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
"core read failed", fmt.Errorf("list tools: %w", err))
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 {
logProblem(r, http.StatusOK, problemCoreReadFailed,
"MCP server status unavailable", fmt.Errorf("read MCP server status: %w", 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})
}