35c6ff5a71
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.
118 lines
4.0 KiB
Go
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})
|
|
}
|