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.
This commit is contained in:
+21
-9
@@ -559,7 +559,7 @@ func run(args []string) error {
|
||||
func personaFacts(cfg *config.Config) persona.Facts {
|
||||
f := persona.Facts{
|
||||
// Telegram lives outside the voice block, so it counts either way.
|
||||
Telegram: cfg.Telegram != nil && cfg.Telegram.BotToken != "" && cfg.Telegram.ChatID != "",
|
||||
Telegram: cfg.Telegram != nil && !cfg.Telegram.Disabled && cfg.Telegram.BotToken != "" && cfg.Telegram.ChatID != "",
|
||||
}
|
||||
if cfg.Voice == nil {
|
||||
return f
|
||||
@@ -678,16 +678,12 @@ func wireGatherer(st *store.Store, cfg *config.Config, rules []loop.Rule) *loop.
|
||||
// reconciled to "unknown" here, before the tick loop resumes sending, so
|
||||
// nothing auto-resends into that ambiguity.
|
||||
func wireDispatcher(st *store.Store, cfg *config.Config, voiceW *voiceWiring) (*delivery.Dispatcher, error) {
|
||||
var ntfy delivery.Sink
|
||||
if cfg.Ntfy != nil {
|
||||
s, err := ntfysink.New(*cfg.Ntfy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wire ntfy sink: %w", err)
|
||||
}
|
||||
ntfy = s
|
||||
ntfy, err := wireNtfySink(cfg.Ntfy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var telegram delivery.Sink
|
||||
if cfg.Telegram != nil {
|
||||
if cfg.Telegram != nil && !cfg.Telegram.Disabled {
|
||||
s, err := telegramsink.New(*cfg.Telegram)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wire telegram sink: %w", err)
|
||||
@@ -712,6 +708,22 @@ func wireDispatcher(st *store.Store, cfg *config.Config, voiceW *voiceWiring) (*
|
||||
}), nil
|
||||
}
|
||||
|
||||
// wireNtfySink keeps an optional reach optional without ever turning a missing
|
||||
// secret into anonymous publishing. A block is live unless it says disabled;
|
||||
// therefore an expanded-empty token in a live block fails startup instead of
|
||||
// spending days in a permanent 403 retry loop. Disabled is an explicit
|
||||
// operator choice and lets another away reach take over.
|
||||
func wireNtfySink(cfg *ntfysink.Config) (delivery.Sink, error) {
|
||||
if cfg == nil || cfg.Disabled {
|
||||
return nil, nil
|
||||
}
|
||||
sink, err := ntfysink.New(*cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wire ntfy sink: %w", err)
|
||||
}
|
||||
return sink, nil
|
||||
}
|
||||
|
||||
// wireTickLoop reads the loop's three intervals and its schedules out of the
|
||||
// config, so the two boot paths cannot disagree about them.
|
||||
func wireTickLoop(st *store.Store, gatherer *loop.Gatherer, dispatcher *delivery.Dispatcher, phr phraser.Phraser, rules []loop.Rule, cfg *config.Config) *tickLoop {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/delivery/ntfysink"
|
||||
)
|
||||
|
||||
func TestWireNtfySinkRejectsMissingCredentialWhenEnabled(t *testing.T) {
|
||||
_, err := wireNtfySink(&ntfysink.Config{
|
||||
BaseURL: "https://ntfy.example", Topic: "maven",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expanded-empty credential did not fail an enabled reach")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWireNtfySinkLeavesExplicitlyDisabledReachDark(t *testing.T) {
|
||||
sink, err := wireNtfySink(&ntfysink.Config{
|
||||
Disabled: true, BaseURL: "https://ntfy.example", Topic: "maven",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("wireNtfySink: %v", err)
|
||||
}
|
||||
if sink != nil {
|
||||
t.Fatal("disabled reach built a live sink")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWireNtfySinkRejectsMalformedEnabledConfig(t *testing.T) {
|
||||
_, err := wireNtfySink(&ntfysink.Config{Token: "token", Topic: "maven"})
|
||||
if err == nil {
|
||||
t.Fatal("malformed enabled config did not fail wiring")
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
// already failed the boot in wireDispatcher for the same config, so a second
|
||||
// hard failure would only lose that message.
|
||||
func wireTelegramIntake(ctx context.Context, wg *sync.WaitGroup, api ipc.CoreAPI, cfg *config.Config) {
|
||||
if cfg == nil || cfg.Telegram == nil || !cfg.Telegram.Intake || api == nil {
|
||||
if cfg == nil || cfg.Telegram == nil || cfg.Telegram.Disabled || !cfg.Telegram.Intake || api == nil {
|
||||
return
|
||||
}
|
||||
sink, err := telegramsink.New(*cfg.Telegram)
|
||||
|
||||
+125
-11
@@ -10,11 +10,13 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -226,18 +228,11 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
// detectPatterns below for how idempotence and dismissal are respected.
|
||||
t.detectPatterns(ctx, now, state)
|
||||
|
||||
// reminders: gate-bypassing class. fired once, marked after a successful
|
||||
// delivery. a failed send leaves the reminder pending — the next tick
|
||||
// re-gathers and re-attempts.
|
||||
// reminders: gate-bypassing class. The presentation and retry clock live on
|
||||
// the reminder occurrence, so a transport outage neither spends the model
|
||||
// every tick nor changes what the reminder says after a restart.
|
||||
for _, d := range loop.RemindDecisions(state, due) {
|
||||
pr, err := t.phraser.PhraseReminder(ctx, d)
|
||||
if err != nil {
|
||||
log.Printf("tick: phrase reminder %d: %v", d.Reminder.ID, err)
|
||||
continue
|
||||
}
|
||||
if _, err := t.dispatcher.DispatchReminder(ctx, pr, now); err != nil {
|
||||
log.Printf("tick: dispatch reminder %d: %v", d.Reminder.ID, err)
|
||||
}
|
||||
t.deliverReminder(ctx, d, now)
|
||||
}
|
||||
|
||||
// sev4-away repeats: re-send un-acked telegram nudges per repeatInterval.
|
||||
@@ -263,6 +258,125 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
// deliverReminder advances one due reminder (or collapsed bundle) through the
|
||||
// durable delivery state. A phrase is cached before the first external send;
|
||||
// every definite failure advances the persisted bounded backoff.
|
||||
func (t *tickLoop) deliverReminder(ctx context.Context, d loop.ReminderDecision, now time.Time) {
|
||||
originals := reminderOriginals(d.Reminder)
|
||||
pr, cached := cachedReminderPhrase(d, originals)
|
||||
if !cached {
|
||||
var err error
|
||||
pr, err = t.phraser.PhraseReminder(ctx, d)
|
||||
if err == nil && pr.Body == "" {
|
||||
err = errors.New("phraser returned an empty reminder body")
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("tick: phrase reminder %d: %v", d.Reminder.ID, err)
|
||||
t.scheduleReminderRetry(ctx, originals, now)
|
||||
return
|
||||
}
|
||||
if pr.Mood == "" {
|
||||
pr.Mood = "neutral"
|
||||
}
|
||||
group := reminderDeliveryGroup(originals)
|
||||
if err := t.store.CacheReminderPhrase(
|
||||
ctx, originals, group, pr.Body, pr.Summary, pr.Mood,
|
||||
); err != nil {
|
||||
// A cancellation or another completion can win while phrasing. Do
|
||||
// not send a presentation that no longer owns every original.
|
||||
log.Printf("tick: cache reminder %d phrase: %v", d.Reminder.ID, err)
|
||||
return
|
||||
}
|
||||
// The store now owns the phrase, but this tick's value predates that
|
||||
// write. Stamp the exact persisted occurrence identity onto the value
|
||||
// handed to the dispatcher so its outbox row can suppress an ambiguous
|
||||
// crash for both a real reminder and a synthetic collapsed bundle.
|
||||
for i := range originals {
|
||||
originals[i].DeliveryGroup = group
|
||||
originals[i].PhraseBody = pr.Body
|
||||
originals[i].PhraseSummary = pr.Summary
|
||||
originals[i].PhraseMood = pr.Mood
|
||||
}
|
||||
if d.Reminder.ID == 0 {
|
||||
d.Reminder.Collapsed = originals
|
||||
} else {
|
||||
d.Reminder = originals[0]
|
||||
}
|
||||
}
|
||||
// A phraser is not allowed to substitute the reminder decision. In
|
||||
// particular, the durable group stamped above must reach the outbox.
|
||||
pr.Decision = d
|
||||
|
||||
if _, err := t.dispatcher.DispatchReminder(ctx, pr, now); err != nil {
|
||||
log.Printf("tick: dispatch reminder %d: %v", d.Reminder.ID, err)
|
||||
t.scheduleReminderRetry(ctx, originals, now)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *tickLoop) scheduleReminderRetry(ctx context.Context, originals []store.Reminder, now time.Time) {
|
||||
if err := t.store.ScheduleReminderRetry(ctx, originals, now); err != nil {
|
||||
log.Printf("tick: schedule reminder retry: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// reminderOriginals converts the synthetic ID=0 bundle back to real store
|
||||
// rows. Keeping this in one helper makes it impossible to accidentally persist
|
||||
// retry state against reminder zero.
|
||||
func reminderOriginals(r store.Reminder) []store.Reminder {
|
||||
if r.ID == 0 {
|
||||
return append([]store.Reminder(nil), r.Collapsed...)
|
||||
}
|
||||
return []store.Reminder{r}
|
||||
}
|
||||
|
||||
// cachedReminderPhrase reconstructs a PhrasedReminder only when every original
|
||||
// agrees on one persisted group and presentation. That agreement is what lets
|
||||
// a collapsed bundle survive a restart without being re-phrased.
|
||||
func cachedReminderPhrase(d loop.ReminderDecision, originals []store.Reminder) (delivery.PhrasedReminder, bool) {
|
||||
if len(originals) == 0 || !originals[0].HasDeliveryPhrase() {
|
||||
return delivery.PhrasedReminder{}, false
|
||||
}
|
||||
first := originals[0]
|
||||
for _, r := range originals[1:] {
|
||||
if !r.HasDeliveryPhrase() ||
|
||||
r.DeliveryGroup != first.DeliveryGroup ||
|
||||
r.PhraseBody != first.PhraseBody ||
|
||||
r.PhraseSummary != first.PhraseSummary ||
|
||||
r.PhraseMood != first.PhraseMood {
|
||||
return delivery.PhrasedReminder{}, false
|
||||
}
|
||||
}
|
||||
mood := first.PhraseMood
|
||||
if mood == "" {
|
||||
mood = "neutral"
|
||||
}
|
||||
return delivery.PhrasedReminder{
|
||||
Decision: d,
|
||||
Body: first.PhraseBody,
|
||||
Summary: first.PhraseSummary,
|
||||
Mood: mood,
|
||||
}, true
|
||||
}
|
||||
|
||||
// reminderDeliveryGroup deterministically names one occurrence or collapsed
|
||||
// set. The next-fire instant is part of the identity so a recurring reminder's
|
||||
// later occurrence can never inherit the previous occurrence's phrase.
|
||||
func reminderDeliveryGroup(originals []store.Reminder) string {
|
||||
ordered := append([]store.Reminder(nil), originals...)
|
||||
sort.Slice(ordered, func(i, j int) bool {
|
||||
if ordered[i].ID == ordered[j].ID {
|
||||
return ordered[i].NextFireTs.Before(ordered[j].NextFireTs)
|
||||
}
|
||||
return ordered[i].ID < ordered[j].ID
|
||||
})
|
||||
h := sha256.New()
|
||||
for _, r := range ordered {
|
||||
_, _ = fmt.Fprintf(h, "%d:%d;", r.ID, r.NextFireTs.UnixMilli())
|
||||
}
|
||||
sum := h.Sum(nil)
|
||||
return fmt.Sprintf("reminder:%x", sum[:12])
|
||||
}
|
||||
|
||||
// savePresence writes back the bucket GatherState just resolved.
|
||||
//
|
||||
// It lives here and not in GatherState because that method holds a read-only
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
type reminderCountingPhraser struct {
|
||||
phraser.Phraser
|
||||
calls int
|
||||
body string
|
||||
summary string
|
||||
mood string
|
||||
}
|
||||
|
||||
func (p *reminderCountingPhraser) PhraseReminder(_ context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error) {
|
||||
p.calls++
|
||||
return delivery.PhrasedReminder{
|
||||
Decision: d,
|
||||
Body: p.body,
|
||||
Summary: p.summary,
|
||||
Mood: p.mood,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type reminderFailSink struct {
|
||||
sends int
|
||||
}
|
||||
|
||||
func (s *reminderFailSink) Send(_ context.Context, _ delivery.Sendable) error {
|
||||
s.sends++
|
||||
return errors.New("transport unavailable")
|
||||
}
|
||||
|
||||
func newReminderDeliveryLoop(t *testing.T, st *store.Store, sink delivery.Sink, p phraser.Phraser) *tickLoop {
|
||||
t.Helper()
|
||||
rules := loop.DefaultRules()
|
||||
return newTickLoop(
|
||||
st,
|
||||
loop.NewGatherer(st, rules),
|
||||
delivery.NewDispatcher(delivery.Config{
|
||||
Voice: sink, Ntfy: sink, Telegram: sink,
|
||||
Nudges: st, Reminders: st, Outbox: st,
|
||||
}),
|
||||
p,
|
||||
rules,
|
||||
time.Second,
|
||||
5*time.Minute,
|
||||
0,
|
||||
nil, nil, nil, nil,
|
||||
)
|
||||
}
|
||||
|
||||
func TestTickReminderRetryUsesPersistedPhraseAfterRestart(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
if _, err := st.CreateReminder(ctx, now.Add(-time.Minute), `{"text":"позвонить маме"}`, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fail := &reminderFailSink{}
|
||||
firstPhraser := &reminderCountingPhraser{
|
||||
Phraser: phraser.NewStub(), body: "Не забудь позвонить маме.",
|
||||
summary: "Позвонить маме", mood: "warm",
|
||||
}
|
||||
tl := newReminderDeliveryLoop(t, st, fail, firstPhraser)
|
||||
tl.tick(ctx, now)
|
||||
if firstPhraser.calls != 1 {
|
||||
t.Fatalf("first tick phrased %d times, want 1", firstPhraser.calls)
|
||||
}
|
||||
rows, err := st.ListReminders(ctx, 1)
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("list = %d, err=%v", len(rows), err)
|
||||
}
|
||||
if !rows[0].HasDeliveryPhrase() || rows[0].DeliveryAttempts != 1 {
|
||||
t.Fatalf("failed delivery state was not persisted: %+v", rows[0])
|
||||
}
|
||||
if want := now.Add(store.ReminderRetryBase); !rows[0].NextAttemptTs.Equal(want) {
|
||||
t.Fatalf("next attempt = %s, want %s", rows[0].NextAttemptTs, want)
|
||||
}
|
||||
|
||||
// A normal tick inside the wait does no transport work and no model work.
|
||||
sendsAfterFirst := fail.sends
|
||||
tl.tick(ctx, now.Add(30*time.Second))
|
||||
if firstPhraser.calls != 1 || fail.sends != sendsAfterFirst {
|
||||
t.Fatalf("retry wait did work: phrase calls=%d, sends=%d (was %d)", firstPhraser.calls, fail.sends, sendsAfterFirst)
|
||||
}
|
||||
|
||||
// Constructing a new loop is the daemon-restart boundary. Its phraser would
|
||||
// say something different if called; the stored phrase must win instead.
|
||||
success := &fakeSink{}
|
||||
afterRestart := &reminderCountingPhraser{
|
||||
Phraser: phraser.NewStub(), body: "WRONG NEW PHRASE", summary: "WRONG", mood: "neutral",
|
||||
}
|
||||
restarted := newReminderDeliveryLoop(t, st, success, afterRestart)
|
||||
restarted.tick(ctx, now.Add(store.ReminderRetryBase))
|
||||
if afterRestart.calls != 0 {
|
||||
t.Fatalf("restart re-phrased the reminder %d times", afterRestart.calls)
|
||||
}
|
||||
if len(success.sends) != 1 {
|
||||
t.Fatalf("retry sends = %d, want 1", len(success.sends))
|
||||
}
|
||||
if got := success.sends[0].Body; got != "Позвонить маме" {
|
||||
t.Fatalf("away retry body = %q, want persisted summary", got)
|
||||
}
|
||||
rows, err = st.ListReminders(ctx, 1)
|
||||
if err != nil || rows[0].Status != store.ReminderFired {
|
||||
t.Fatalf("successful retry did not fire reminder: rows=%+v err=%v", rows, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTickCollapsedReminderRetriesOnePhraseAndCompletesOriginals(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
for _, text := range []string{"полить цветы", "записаться к врачу"} {
|
||||
if _, err := st.CreateReminder(ctx, now.Add(-time.Minute), text, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
fail := &reminderFailSink{}
|
||||
firstPhraser := &reminderCountingPhraser{
|
||||
Phraser: phraser.NewStub(), body: "У тебя два напоминания.",
|
||||
summary: "Два напоминания", mood: "neutral",
|
||||
}
|
||||
newReminderDeliveryLoop(t, st, fail, firstPhraser).tick(ctx, now)
|
||||
if firstPhraser.calls != 1 {
|
||||
t.Fatalf("collapsed bundle phrased %d times, want 1", firstPhraser.calls)
|
||||
}
|
||||
rows, err := st.ListReminders(ctx, 10)
|
||||
if err != nil || len(rows) != 2 {
|
||||
t.Fatalf("list = %d, err=%v", len(rows), err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.DeliveryGroup == "" || r.DeliveryGroup != rows[0].DeliveryGroup ||
|
||||
r.PhraseBody != "У тебя два напоминания." || r.DeliveryAttempts != 1 {
|
||||
t.Fatalf("collapsed original lost shared state: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
success := &fakeSink{}
|
||||
afterRestart := &reminderCountingPhraser{
|
||||
Phraser: phraser.NewStub(), body: "WRONG", summary: "WRONG", mood: "neutral",
|
||||
}
|
||||
newReminderDeliveryLoop(t, st, success, afterRestart).tick(ctx, now.Add(store.ReminderRetryBase))
|
||||
if afterRestart.calls != 0 {
|
||||
t.Fatalf("collapsed retry re-phrased %d times", afterRestart.calls)
|
||||
}
|
||||
if len(success.sends) != 1 || success.sends[0].ReminderID != 0 {
|
||||
t.Fatalf("collapsed retry sends = %+v, want one synthetic delivery", success.sends)
|
||||
}
|
||||
rows, err = st.ListReminders(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.Status != store.ReminderFired {
|
||||
t.Fatalf("collapsed original %d status = %q, want fired", r.ID, r.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -377,19 +377,19 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
// degraded mode, so the seam is nil and the cascade routes with the classifier.
|
||||
func modelSeam(cfg *config.Config, resident *llm.Client) (router.Completer, *llm.Pair) {
|
||||
if resident == nil {
|
||||
if cfg.Workstation != nil {
|
||||
if cfg.Workstation != nil && !cfg.Workstation.ModelDisabled {
|
||||
log.Printf("voice: a workstation is configured but there is no resident model to floor it with — ignoring the block")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if cfg.Workstation == nil {
|
||||
if cfg.Workstation == nil || cfg.Workstation.ModelDisabled {
|
||||
return resident, nil
|
||||
}
|
||||
ws := cfg.Workstation
|
||||
remote := llm.New(ws.URL, time.Duration(ws.Timeout))
|
||||
remote.SetToken(ws.Token)
|
||||
if ws.Token == "" {
|
||||
log.Printf("voice: no workstation.token — mavgpud refuses an unauthenticated request, so this reads as a card that is always busy")
|
||||
log.Printf("voice: unauthenticated workstation model endpoint is loopback-only")
|
||||
}
|
||||
pair := llm.NewPair(
|
||||
remote,
|
||||
@@ -434,7 +434,7 @@ func sttSeam(cfg *config.Config, floor stt.Transcriber) (stt.Transcriber, *stt.P
|
||||
)
|
||||
pair.Start(context.Background())
|
||||
if s.Token == "" {
|
||||
log.Print("voice: the workstation transcriber has no token, so anything on the LAN can post audio to it")
|
||||
log.Print("voice: unauthenticated workstation transcriber endpoint is loopback-only")
|
||||
}
|
||||
log.Printf("voice: workstation transcriber at %s, probed every %s, mavsttd as the floor",
|
||||
s.URL, time.Duration(s.Probe))
|
||||
|
||||
+19
-11
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -51,34 +52,41 @@ type ambientResp struct {
|
||||
// never registered, so it is treated as a hard failure here too.
|
||||
func handleAmbient(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, token string) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
writeProblem(w, r, http.StatusMethodNotAllowed, problemMethodNotAllowed,
|
||||
"POST only", nil)
|
||||
return
|
||||
}
|
||||
if token == "" {
|
||||
http.Error(w, "ambient ingest disabled (no -ambient-token)", http.StatusServiceUnavailable)
|
||||
writeProblem(w, r, http.StatusServiceUnavailable, problemIntegrationOff,
|
||||
"ambient ingest disabled (no -ambient-token)", nil)
|
||||
return
|
||||
}
|
||||
if !ambientAuthorized(r, token) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
writeProblem(w, r, http.StatusUnauthorized, problemUnauthorized,
|
||||
"unauthorized", nil)
|
||||
return
|
||||
}
|
||||
if core == nil {
|
||||
http.Error(w, "ambient ingest disabled (no -core)", http.StatusServiceUnavailable)
|
||||
writeProblem(w, r, http.StatusServiceUnavailable, problemCoreUnavailable,
|
||||
"ambient ingest disabled (no -core)", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var n calendar.Notification
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, ambientMaxBody))
|
||||
if err != nil {
|
||||
http.Error(w, "read failed", http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"read failed", fmt.Errorf("read ambient request: %w", err))
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(body, &n); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"bad json", fmt.Errorf("decode ambient request: %w", err))
|
||||
return
|
||||
}
|
||||
if n.Posted.IsZero() {
|
||||
writeAmbient(w, http.StatusBadRequest, ambientResp{Reason: "posted_at is required"})
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"posted_at is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -99,8 +107,8 @@ func handleAmbient(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, tok
|
||||
writeAmbient(w, http.StatusOK, ambientResp{Stored: false, Key: key, Reason: "unchanged"})
|
||||
return
|
||||
} else if err != nil && !errors.Is(err, ipc.ErrNoFact) {
|
||||
log.Printf("ambient: read %s: %v", key, err)
|
||||
http.Error(w, "read failed", http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
||||
"read failed", fmt.Errorf("read ambient fact %q: %w", key, err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -116,8 +124,8 @@ func handleAmbient(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, tok
|
||||
Source: calendar.SourceAmbient,
|
||||
Confidence: calendar.AmbientConfidence,
|
||||
}); err != nil {
|
||||
log.Printf("ambient: write %s: %v", key, err)
|
||||
http.Error(w, "write failed", http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreWriteFailed,
|
||||
"write failed", fmt.Errorf("write ambient fact %q: %w", key, err))
|
||||
return
|
||||
}
|
||||
log.Printf("ambient: %s=%s (%s, pkg=%s)", key, val, calendar.SourceAmbient, n.Package)
|
||||
|
||||
@@ -16,6 +16,18 @@ import (
|
||||
|
||||
const ambientTestToken = "s3cret"
|
||||
|
||||
func TestValidateAmbientConfig(t *testing.T) {
|
||||
if err := validateAmbientConfig(false, ""); err != nil {
|
||||
t.Fatalf("explicitly disabled ambient config: %v", err)
|
||||
}
|
||||
if err := validateAmbientConfig(true, ""); err == nil {
|
||||
t.Fatal("enabled ambient ingest accepted an empty token")
|
||||
}
|
||||
if err := validateAmbientConfig(true, ambientTestToken); err != nil {
|
||||
t.Fatalf("enabled authenticated ambient config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ambientCore adds provenance-scoped reads to fakeCore, which the dedupe path
|
||||
// needs.
|
||||
type ambientCore struct {
|
||||
|
||||
+20
-15
@@ -3,7 +3,7 @@ package main
|
||||
import (
|
||||
_ "embed"
|
||||
"errors"
|
||||
"log"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -47,7 +47,7 @@ var correctionTargets = []router.Intent{
|
||||
|
||||
// handleChatPage renders the chat conversation page.
|
||||
func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "chat") {
|
||||
if !requireCore(w, r, core, "chat") {
|
||||
return
|
||||
}
|
||||
msgs := []chatMsg{}
|
||||
@@ -83,13 +83,14 @@ func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
// 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)
|
||||
writeProblem(w, r, http.StatusMethodNotAllowed, problemMethodNotAllowed,
|
||||
"POST only", nil)
|
||||
return
|
||||
}
|
||||
if !requireCore(w, core, "chat") {
|
||||
if !requireCore(w, r, core, "chat") {
|
||||
return
|
||||
}
|
||||
if !stepUpGate(w, session, requireStepUp) {
|
||||
if !stepUpGate(w, r, session, requireStepUp) {
|
||||
return
|
||||
}
|
||||
text := strings.TrimSpace(r.FormValue("text"))
|
||||
@@ -105,8 +106,8 @@ func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, ses
|
||||
// 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)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreChangeFailed,
|
||||
"chat failed", fmt.Errorf("run web chat turn: %w", err))
|
||||
return
|
||||
}
|
||||
// The claiming query source rides back on the redirect so the page can show
|
||||
@@ -138,36 +139,40 @@ func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, ses
|
||||
// id could otherwise mislabel turns he never corrected.
|
||||
func handleCorrectAPI(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)
|
||||
writeProblem(w, r, http.StatusMethodNotAllowed, problemMethodNotAllowed,
|
||||
"POST only", nil)
|
||||
return
|
||||
}
|
||||
if !requireCore(w, core, "correct") {
|
||||
if !requireCore(w, r, core, "correct") {
|
||||
return
|
||||
}
|
||||
if !stepUpGate(w, session, requireStepUp) {
|
||||
if !stepUpGate(w, r, session, requireStepUp) {
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("trace_id")), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
http.Error(w, "trace_id required", http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"trace_id required", err)
|
||||
return
|
||||
}
|
||||
shouldBe := strings.TrimSpace(r.FormValue("should_be"))
|
||||
// Only one of the seven, or nothing. Free text here would put an unroutable
|
||||
// label in the one table V-632 fits prototypes from.
|
||||
if shouldBe != "" && !isCorrectionTarget(shouldBe) {
|
||||
http.Error(w, "should_be must be one of the seven intents", http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"should_be must be one of the seven intents", nil)
|
||||
return
|
||||
}
|
||||
if err := core.CorrectTurn(r.Context(), id, shouldBe); err != nil {
|
||||
log.Printf("correct turn %d: %v", id, err)
|
||||
// A turn past the retention bound is gone, and saying so is different
|
||||
// from saying the write broke.
|
||||
if errors.Is(err, ipc.ErrNoSuchTrace) {
|
||||
http.Error(w, "that turn is no longer stored", http.StatusNotFound)
|
||||
writeProblem(w, r, http.StatusNotFound, problemResourceNotFound,
|
||||
"that turn is no longer stored", fmt.Errorf("correct turn %d: %w", id, err))
|
||||
return
|
||||
}
|
||||
http.Error(w, "correction failed", http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreChangeFailed,
|
||||
"correction failed", fmt.Errorf("correct turn %d: %w", id, err))
|
||||
return
|
||||
}
|
||||
stamp := shouldBe
|
||||
|
||||
+14
-2
@@ -33,17 +33,29 @@ func getEco(ctx context.Context, base, path string, out any) string {
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+path, nil)
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
log.Printf("ecosystem panel request_id=%s build %q: %v", requestIDFromContext(ctx), path, err)
|
||||
return "invalid endpoint"
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-Requested-By", "mavweb")
|
||||
// These are direct browser-surface reads rather than an action initiated in
|
||||
// mavend, so the HTTP request ID is the natural correlation root. Calls that
|
||||
// pass through core mint their action correlation inside mavend instead.
|
||||
if id := requestIDFromContext(ctx); id != "" {
|
||||
req.Header.Set("X-Correlation-ID", id)
|
||||
}
|
||||
resp, err := ecoClient.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("ecosystem panel request_id=%s GET %s: %v", requestIDFromContext(ctx), path, err)
|
||||
return "unreachable"
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
log.Printf("ecosystem panel request_id=%s GET %s: HTTP %d", requestIDFromContext(ctx), path, resp.StatusCode)
|
||||
return fmt.Sprintf("http %d", resp.StatusCode)
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
log.Printf("ecosystem panel request_id=%s decode %s: %v", requestIDFromContext(ctx), path, err)
|
||||
return "bad json"
|
||||
}
|
||||
return ""
|
||||
@@ -111,7 +123,7 @@ func handleEcosystem(w http.ResponseWriter, r *http.Request, urls ecoURLs, core
|
||||
if core == nil {
|
||||
d.Calls.Err = "not configured"
|
||||
} else if rows, err := core.RecentEcosystemTraces(ctx, 50); err != nil {
|
||||
log.Printf("ecosystem traces: %v", err)
|
||||
log.Printf("ecosystem traces request_id=%s: %v", requestIDFromContext(ctx), err)
|
||||
d.Calls.Err = "core read failed"
|
||||
} else {
|
||||
d.Calls.Rows = rows
|
||||
|
||||
@@ -75,8 +75,13 @@ func TestEventsPageReportsAReadFailure(t *testing.T) {
|
||||
t.Fatalf("status = %d, want 200 with the error rendered", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "journal unavailable") || !strings.Contains(body, "core is down") {
|
||||
t.Errorf("page did not report the read failure: %s", body)
|
||||
if !strings.Contains(body, "intake journal unavailable") ||
|
||||
!strings.Contains(body, string(problemCoreReadFailed)) ||
|
||||
!strings.Contains(body, "request ") {
|
||||
t.Errorf("page did not report a traceable, sanitized read failure: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "core is down") {
|
||||
t.Errorf("page disclosed the internal read error: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "nothing has arrived yet") {
|
||||
t.Error("a failed read rendered as an empty journal")
|
||||
|
||||
+18
-12
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -32,16 +33,18 @@ var presenceSignals = map[string]string{
|
||||
// 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)
|
||||
writeProblem(w, r, http.StatusMethodNotAllowed, problemMethodNotAllowed,
|
||||
"POST only", nil)
|
||||
return
|
||||
}
|
||||
if !requireCore(w, core, "presence ingest") {
|
||||
if !requireCore(w, r, core, "presence ingest") {
|
||||
return
|
||||
}
|
||||
key := r.URL.Query().Get("key")
|
||||
source, ok := presenceSignals[key]
|
||||
if !ok {
|
||||
http.Error(w, "unknown signal key", http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"unknown signal key", nil)
|
||||
return
|
||||
}
|
||||
// kind=env: an observation about the device/surface, NOT a self-fact — a
|
||||
@@ -56,8 +59,8 @@ func handleSignal(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
Source: source,
|
||||
Confidence: 1.0,
|
||||
}); err != nil {
|
||||
log.Printf("signal %s: %v", key, err)
|
||||
http.Error(w, "write failed", http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreWriteFailed,
|
||||
"write failed", fmt.Errorf("write presence signal %q: %w", key, err))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -65,29 +68,32 @@ func handleSignal(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
|
||||
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)
|
||||
writeProblem(w, r, http.StatusMethodNotAllowed, problemMethodNotAllowed,
|
||||
"POST only", nil)
|
||||
return
|
||||
}
|
||||
if !requireCore(w, core, "revert") {
|
||||
if !requireCore(w, r, core, "revert") {
|
||||
return
|
||||
}
|
||||
if !stepUpGate(w, session, requireStepUp) {
|
||||
if !stepUpGate(w, r, session, requireStepUp) {
|
||||
return
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(r.FormValue("key"))
|
||||
if key == "" {
|
||||
http.Error(w, "key required", http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"key required", nil)
|
||||
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)
|
||||
writeProblem(w, r, http.StatusNotFound, problemResourceNotFound,
|
||||
"no fact to revert", fmt.Errorf("revert fact %q: %w", key, err))
|
||||
return
|
||||
}
|
||||
http.Error(w, "revert failed", http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreChangeFailed,
|
||||
"revert failed", fmt.Errorf("revert fact %q: %w", key, err))
|
||||
return
|
||||
}
|
||||
log.Printf("reverted fact for key=%s, new_id=%d", key, newID)
|
||||
|
||||
+18
-6
@@ -48,9 +48,14 @@ func main() {
|
||||
hexisURL := flag.String("hexis", "", "Hexis base URL for the /ecosystem panel (empty = not configured)")
|
||||
// Shared secret for POST /api/ambient, the notification-relay ingest that
|
||||
// reads the work calendar as a signal instead of holding a work credential
|
||||
// (see ambient.go). Empty ⇒ the route is not registered at all.
|
||||
ambientToken := flag.String("ambient-token", "", "shared secret for POST /api/ambient notification ingest (empty = ingest disabled, route not registered)")
|
||||
// (see ambient.go). Enabling and authenticating are separate on purpose: an
|
||||
// expanded-empty secret cannot silently turn a live integration off.
|
||||
ambientEnabled := flag.Bool("ambient-enabled", false, "enable POST /api/ambient notification ingest (requires -ambient-token)")
|
||||
ambientToken := flag.String("ambient-token", "", "shared secret for POST /api/ambient notification ingest")
|
||||
flag.Parse()
|
||||
if err := validateAmbientConfig(*ambientEnabled, *ambientToken); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var core ipc.CoreAPI
|
||||
// swapConn — a second connection, for /models and nothing else. A model swap
|
||||
@@ -129,9 +134,9 @@ func main() {
|
||||
w.Write([]byte(*ntfyWS))
|
||||
})
|
||||
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 != "" {
|
||||
// Off unless explicitly enabled: a dark ingest has no route at all, while an
|
||||
// enabled ingest with no token was rejected before the server was built.
|
||||
if *ambientEnabled {
|
||||
mux.HandleFunc("/api/ambient", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleAmbient(w, r, core, *ambientToken)
|
||||
})
|
||||
@@ -251,6 +256,13 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func validateAmbientConfig(enabled bool, token string) error {
|
||||
if enabled && token == "" {
|
||||
return errors.New("mavweb: ambient ingest is enabled but -ambient-token is empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -288,7 +300,7 @@ const (
|
||||
func mavwebHTTPServer(addr string, handler http.Handler) *http.Server {
|
||||
return &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
Handler: withRequestID(handler),
|
||||
ReadHeaderTimeout: mavwebReadHeaderTimeout,
|
||||
ReadTimeout: mavwebReadTimeout,
|
||||
IdleTimeout: mavwebIdleTimeout,
|
||||
|
||||
+25
-14
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -57,7 +58,8 @@ type modelsPage struct {
|
||||
// the reply. On its own connection the swap only blocks the swap.
|
||||
func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, swapConn modelController, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
if core == nil {
|
||||
http.Error(w, "models disabled (no -core)", http.StatusServiceUnavailable)
|
||||
writeProblem(w, r, http.StatusServiceUnavailable, problemCoreUnavailable,
|
||||
"models disabled (no -core)", nil)
|
||||
return
|
||||
}
|
||||
mc, ok := swapConn, swapConn != nil
|
||||
@@ -65,7 +67,8 @@ func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, swap
|
||||
mc, ok = core.(modelController)
|
||||
}
|
||||
if !ok {
|
||||
http.Error(w, "models unavailable: core connection does not support model swap", http.StatusServiceUnavailable)
|
||||
writeProblem(w, r, http.StatusServiceUnavailable, problemModelsUnavailable,
|
||||
"models unavailable: core connection does not support model swap", nil)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
@@ -73,12 +76,14 @@ func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, swap
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
if !stepUpOK(session, requireStepUp) {
|
||||
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||
writeProblem(w, r, http.StatusForbidden, problemStepUpRequired,
|
||||
"step-up required: assert a passkey first", nil)
|
||||
return
|
||||
}
|
||||
path := strings.TrimSpace(r.FormValue("model_path"))
|
||||
if path == "" {
|
||||
http.Error(w, "model_path required", http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"model_path required", nil)
|
||||
return
|
||||
}
|
||||
// Only the path comes off the form. n_ctx and n_gpu_layers are load
|
||||
@@ -93,20 +98,26 @@ func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, swap
|
||||
page.Msg = "loaded " + res.Model + " (" + strconv.FormatInt(res.TookMs, 10) + "ms)"
|
||||
log.Printf("models: swapped to %s (%s) in %dms", res.ModelPath, res.Model, res.TookMs)
|
||||
case errors.Is(err, ipc.ErrForbidden):
|
||||
http.Error(w, "refused: that model is not in phraser.swap_models, or step-up was not asserted", http.StatusForbidden)
|
||||
writeProblem(w, r, http.StatusForbidden, problemModelsForbidden,
|
||||
"refused: that model is not in phraser.swap_models, or step-up was not asserted",
|
||||
fmt.Errorf("swap model %q: %w", path, err))
|
||||
return
|
||||
case errors.Is(err, ipc.ErrUnknownMethod):
|
||||
http.Error(w, "swap not configured on this core", http.StatusServiceUnavailable)
|
||||
writeProblem(w, r, http.StatusServiceUnavailable, problemModelsUnavailable,
|
||||
"swap not configured on this core", fmt.Errorf("swap model %q: %w", path, err))
|
||||
return
|
||||
case res.NoBackend:
|
||||
page.Err = "swap failed AND the rollback failed — no model is loaded. She is answering from templates and routing on the classifier. Try loading a model again; a restart is not needed."
|
||||
log.Printf("models: swap to %s failed and the rollback failed, no model loaded: %v", path, err)
|
||||
page.Err = inlineProblem(r, problemModelsUnavailable,
|
||||
"swap failed AND the rollback failed — no model is loaded. She is answering from templates and routing on the classifier. Try loading a model again; a restart is not needed.",
|
||||
fmt.Errorf("swap model %q and rollback: %w", path, err))
|
||||
case res.RolledBack:
|
||||
page.Err = "swap failed, rolled back to " + res.Model + " — she is still answering, with the old model"
|
||||
log.Printf("models: swap to %s failed, rolled back: %v", path, err)
|
||||
page.Err = inlineProblem(r, problemModelsUnavailable,
|
||||
"swap failed, rolled back to "+res.Model+" — she is still answering, with the old model",
|
||||
fmt.Errorf("swap model %q, rolled back to %q: %w", path, res.Model, err))
|
||||
default:
|
||||
page.Err = "swap failed: " + err.Error()
|
||||
log.Printf("models: swap to %s failed: %v", path, err)
|
||||
page.Err = inlineProblem(r, problemModelsUnavailable,
|
||||
"swap failed; the current model state is shown below",
|
||||
fmt.Errorf("swap model %q: %w", path, err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,8 +126,8 @@ func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, swap
|
||||
if errors.Is(err, ipc.ErrUnknownMethod) {
|
||||
page.Off = true
|
||||
} else {
|
||||
log.Printf("models: status: %v", err)
|
||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
||||
"core read failed", fmt.Errorf("read model status: %w", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +149,27 @@ type errBrokenModel struct{}
|
||||
|
||||
func (errBrokenModel) Error() string { return "llm: server did not start" }
|
||||
|
||||
type errPrivateModel struct{}
|
||||
|
||||
func (errPrivateModel) Error() string { return "exec /private/llama-server: token rejected" }
|
||||
|
||||
func TestModels_SwapFailureIsSanitizedAndTraceable(t *testing.T) {
|
||||
core := &fakeModelCore{
|
||||
swapErr: errPrivateModel{},
|
||||
status: ipc.ModelStatusResp{Model: "qwen3", ModelPath: "/m/old.gguf"},
|
||||
}
|
||||
w := modelsPOST(t, core, nil, false, "/m/cpt.gguf")
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{"swap failed", string(problemModelsUnavailable), "request "} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("sanitized model error missing %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "/private/llama-server") || strings.Contains(body, "token rejected") {
|
||||
t.Errorf("model page disclosed the backend error:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModels_TotalFailureDoesNotSaySheIsStillAnswering(t *testing.T) {
|
||||
// The load failed and so did the rollback: nothing is loaded. The page used
|
||||
// to branch on RolledBack first and render "rolled back to — she is still
|
||||
|
||||
@@ -2,7 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"log"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -48,14 +48,14 @@ func deliveryRows(as []ipc.DeliveryAttempt) []deliveryRow {
|
||||
}
|
||||
|
||||
func handleNotifications(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "notifications") {
|
||||
if !requireCore(w, r, 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)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
||||
"notifications unavailable", fmt.Errorf("read recent nudges: %w", err))
|
||||
return
|
||||
}
|
||||
// The outbox, on the page that already answers "what did she send".
|
||||
@@ -66,7 +66,8 @@ func handleNotifications(w http.ResponseWriter, r *http.Request, core ipc.CoreAP
|
||||
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)
|
||||
logProblem(r, http.StatusOK, problemCoreReadFailed,
|
||||
"delivery attempts unavailable", fmt.Errorf("read delivery attempts: %w", err))
|
||||
}
|
||||
renderPage(w, notificationsTmpl, map[string]any{
|
||||
"Nudges": nudges,
|
||||
|
||||
+20
-19
@@ -3,8 +3,8 @@ package main
|
||||
import (
|
||||
"cmp"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -73,7 +73,7 @@ var voiceTmpl = parsePage("voice", voiceHTML, nil)
|
||||
var ecosystemTmpl = parsePage("ecosystem", ecosystemHTML, nil)
|
||||
|
||||
func handleDash(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "dash") {
|
||||
if !requireCore(w, r, core, "dash") {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
@@ -82,8 +82,8 @@ func handleDash(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
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)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
||||
"core read failed", fmt.Errorf("read dashboard: %w", err))
|
||||
return
|
||||
}
|
||||
renderPage(w, dashTmpl, struct {
|
||||
@@ -95,13 +95,13 @@ func handleDash(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
}
|
||||
|
||||
func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "history") {
|
||||
if !requireCore(w, r, 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)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
||||
"core read failed", fmt.Errorf("read fact history: %w", err))
|
||||
return
|
||||
}
|
||||
renderPage(w, historyTmpl, struct {
|
||||
@@ -110,13 +110,13 @@ func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
}
|
||||
|
||||
func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "trace") {
|
||||
if !requireCore(w, r, 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)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
||||
"core read failed", fmt.Errorf("read tick trace: %w", err))
|
||||
return
|
||||
}
|
||||
// The turn records share this page rather than getting one of their own
|
||||
@@ -127,7 +127,8 @@ func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
// deploy.
|
||||
turns, err := core.TurnDecisions(r.Context(), 25)
|
||||
if err != nil {
|
||||
log.Printf("trace: turn decisions: %v", err)
|
||||
logProblem(r, http.StatusOK, problemCoreReadFailed,
|
||||
"turn decisions unavailable", fmt.Errorf("read turn decisions: %w", err))
|
||||
}
|
||||
renderPage(w, traceTmpl, traceData{Tick: trace, Turns: turns})
|
||||
}
|
||||
@@ -149,14 +150,14 @@ type morningView struct {
|
||||
}
|
||||
|
||||
func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "morning") {
|
||||
if !requireCore(w, r, 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)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
||||
"core read failed", fmt.Errorf("read morning status: %w", err))
|
||||
return
|
||||
}
|
||||
view := morningView{Routines: status}
|
||||
@@ -165,8 +166,8 @@ func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
// 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()
|
||||
view.PlanErr = inlineProblem(r, problemCoreReadFailed,
|
||||
"day plan unavailable", fmt.Errorf("read day plan: %w", err))
|
||||
} else {
|
||||
view.Plan = &plan
|
||||
}
|
||||
@@ -186,14 +187,14 @@ type eventsView struct {
|
||||
const eventsPageLimit = 200
|
||||
|
||||
func handleEvents(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "intake journal") {
|
||||
if !requireCore(w, r, 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()
|
||||
view.Err = inlineProblem(r, problemCoreReadFailed,
|
||||
"intake journal unavailable", fmt.Errorf("read intake journal: %w", err))
|
||||
} else {
|
||||
view.Events = evs
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
// problemCode is the stable, low-cardinality name a client can key on. The
|
||||
// request ID identifies one occurrence; the code identifies the class of
|
||||
// failure without exposing the wrapped implementation error.
|
||||
type problemCode string
|
||||
|
||||
const (
|
||||
problemMethodNotAllowed problemCode = "request.method_not_allowed"
|
||||
problemInvalidRequest problemCode = "request.invalid"
|
||||
problemPayloadTooLarge problemCode = "request.payload_too_large"
|
||||
problemUnauthorized problemCode = "auth.unauthorized"
|
||||
problemStepUpRequired problemCode = "auth.step_up_required"
|
||||
problemResourceNotFound problemCode = "resource.not_found"
|
||||
problemIntegrationOff problemCode = "integration.disabled"
|
||||
problemCoreUnavailable problemCode = "core.unavailable"
|
||||
problemCoreReadFailed problemCode = "core.read_failed"
|
||||
problemCoreWriteFailed problemCode = "core.write_failed"
|
||||
problemCoreChangeFailed problemCode = "core.change_failed"
|
||||
problemToolsChange problemCode = "tools.change_failed"
|
||||
problemRoutinesChange problemCode = "routines.change_failed"
|
||||
problemModelsUnavailable problemCode = "models.unavailable"
|
||||
problemModelsForbidden problemCode = "models.forbidden"
|
||||
problemWebAuthnBegin problemCode = "webauthn.begin_failed"
|
||||
problemWebAuthnFinish problemCode = "webauthn.finish_failed"
|
||||
problemWebAuthnStepUp problemCode = "webauthn.step_up_failed"
|
||||
problemVoiceUnavailable problemCode = "voice.unavailable"
|
||||
problemVoiceTransport problemCode = "voice.transport_failed"
|
||||
problemVoiceResponse problemCode = "voice.response_failed"
|
||||
)
|
||||
|
||||
type requestIDKey struct{}
|
||||
|
||||
// problemLogger is separate from the package-wide logger so the contract test
|
||||
// can capture exactly one problem line without redirecting unrelated output.
|
||||
var problemLogger = log.New(os.Stderr, "", log.LstdFlags)
|
||||
|
||||
// problemResponse is the one non-success envelope returned by mavweb. Error is
|
||||
// deliberately a public message, never err.Error(). Code is stable across
|
||||
// occurrences; request_id joins this answer to the full server-side log line.
|
||||
type problemResponse struct {
|
||||
Error string `json:"error"`
|
||||
Code problemCode `json:"code"`
|
||||
RequestID string `json:"request_id"`
|
||||
}
|
||||
|
||||
// withRequestID mints the request identifier at the HTTP boundary. A caller's
|
||||
// X-Request-ID is ignored: accepting it would let an untrusted client forge a
|
||||
// link to another request's logs. The generated ID is also returned on success,
|
||||
// which lets an operator start from any surprising response, not errors alone.
|
||||
func withRequestID(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := rand.Text()
|
||||
w.Header().Set("X-Request-ID", id)
|
||||
ctx := context.WithValue(r.Context(), requestIDKey{}, id)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func requestIDFromContext(ctx context.Context) string {
|
||||
id, _ := ctx.Value(requestIDKey{}).(string)
|
||||
return id
|
||||
}
|
||||
|
||||
func problemRequestID(r *http.Request) string {
|
||||
id := requestIDFromContext(r.Context())
|
||||
if id == "" {
|
||||
id = rand.Text()
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func logProblem(r *http.Request, status int, code problemCode, public string, err error) string {
|
||||
id := problemRequestID(r)
|
||||
if err == nil {
|
||||
err = errors.New(public)
|
||||
}
|
||||
problemLogger.Printf("mavweb problem request_id=%s code=%s status=%d method=%s path=%q: %v",
|
||||
id, code, status, r.Method, r.URL.Path, err)
|
||||
return id
|
||||
}
|
||||
|
||||
// inlineProblem preserves a useful partial page when one panel fails, while
|
||||
// applying the same disclosure and correlation rules as an HTTP problem.
|
||||
func inlineProblem(r *http.Request, code problemCode, public string, err error) string {
|
||||
id := logProblem(r, http.StatusOK, code, public, err)
|
||||
return fmt.Sprintf("%s (code %s, request %s)", public, code, id)
|
||||
}
|
||||
|
||||
// writeProblem is the only mavweb HTTP error writer. The wrapped error is
|
||||
// logged in full and only the explicit public message, stable code and request
|
||||
// ID cross the HTTP boundary.
|
||||
func writeProblem(w http.ResponseWriter, r *http.Request, status int, code problemCode, public string, err error) {
|
||||
// Unit-level handlers and embedders may call a handler without installing
|
||||
// the server middleware. They still get the same traceable contract.
|
||||
id := logProblem(r, status, code, public, err)
|
||||
w.Header().Set("X-Request-ID", id)
|
||||
|
||||
w.Header().Set("Content-Type", "application/problem+json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
// A failed client connection leaves nowhere useful to report an encoder
|
||||
// error; the full problem is already in the server log before this write.
|
||||
_ = json.NewEncoder(w).Encode(problemResponse{
|
||||
Error: public, Code: code, RequestID: id,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriteProblemSanitizesAndCorrelates(t *testing.T) {
|
||||
var logs bytes.Buffer
|
||||
old := problemLogger
|
||||
problemLogger = logForTest(&logs)
|
||||
t.Cleanup(func() { problemLogger = old })
|
||||
|
||||
const id = "TESTREQUESTID"
|
||||
r := httptest.NewRequest(http.MethodPost, "/tools", nil)
|
||||
r = r.WithContext(context.WithValue(r.Context(), requestIDKey{}, id))
|
||||
w := httptest.NewRecorder()
|
||||
internal := errors.New("dial unix /run/private/mavend.sock: bearer secret-token")
|
||||
writeProblem(w, r, http.StatusBadGateway, problemToolsChange, "enable failed", internal)
|
||||
|
||||
if w.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d, want 502", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("X-Request-ID"); got != id {
|
||||
t.Fatalf("X-Request-ID = %q, want %q", got, id)
|
||||
}
|
||||
if got := w.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/problem+json") {
|
||||
t.Fatalf("Content-Type = %q", got)
|
||||
}
|
||||
var got problemResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode problem: %v", err)
|
||||
}
|
||||
if got.Error != "enable failed" || got.Code != problemToolsChange || got.RequestID != id {
|
||||
t.Fatalf("problem = %+v", got)
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "private") || strings.Contains(w.Body.String(), "secret-token") {
|
||||
t.Fatalf("HTTP response disclosed the wrapped error: %s", w.Body.String())
|
||||
}
|
||||
for _, want := range []string{id, string(problemToolsChange), internal.Error()} {
|
||||
if !strings.Contains(logs.String(), want) {
|
||||
t.Errorf("server log missing %q: %s", want, logs.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestIDMiddlewareMintsAndIgnoresCallerID(t *testing.T) {
|
||||
var seen string
|
||||
h := withRequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seen = requestIDFromContext(r.Context())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
|
||||
r.Header.Set("X-Request-ID", "caller-chosen")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
|
||||
if seen == "" || seen == "caller-chosen" {
|
||||
t.Fatalf("request ID = %q; want a server-generated value", seen)
|
||||
}
|
||||
if got := w.Header().Get("X-Request-ID"); got != seen {
|
||||
t.Fatalf("response request ID = %q, context ID = %q", got, seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEcosystemPanelPropagatesRequestID(t *testing.T) {
|
||||
const id = "WEBREQUESTCORRELATION"
|
||||
var correlation, requester string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
correlation = r.Header.Get("X-Correlation-ID")
|
||||
requester = r.Header.Get("X-Requested-By")
|
||||
_, _ = io.WriteString(w, `[]`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
ctx := context.WithValue(context.Background(), requestIDKey{}, id)
|
||||
var rows []ecoEntity
|
||||
if got := getEco(ctx, srv.URL, "/entities", &rows); got != "" {
|
||||
t.Fatalf("getEco error = %q", got)
|
||||
}
|
||||
if correlation != id || requester != "mavweb" {
|
||||
t.Fatalf("correlation = %q, requester = %q", correlation, requester)
|
||||
}
|
||||
}
|
||||
|
||||
// The contract is architectural, not a convention people must remember. Keep
|
||||
// a syntax-level guard so a new handler cannot bypass writeProblem by adding
|
||||
// another http.Error call.
|
||||
func TestProductionHandlersUseOneProblemWriter(t *testing.T) {
|
||||
entries, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
||||
continue
|
||||
}
|
||||
file, err := parser.ParseFile(token.NewFileSet(), filepath.Clean(name), nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", name, err)
|
||||
}
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != "Error" {
|
||||
return true
|
||||
}
|
||||
pkg, ok := sel.X.(*ast.Ident)
|
||||
if ok && pkg.Name == "http" {
|
||||
t.Errorf("%s contains http.Error; use writeProblem", name)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func logForTest(w io.Writer) *log.Logger {
|
||||
return log.New(w, "", 0)
|
||||
}
|
||||
+15
-5
@@ -3,7 +3,7 @@ package main
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -26,6 +26,7 @@ type reminderRow struct {
|
||||
Created string
|
||||
Fires string
|
||||
Status string
|
||||
Detail string
|
||||
Text string
|
||||
}
|
||||
|
||||
@@ -50,10 +51,19 @@ func reminderText(payload string) string {
|
||||
func reminderRows(rs []ipc.Reminder) []reminderRow {
|
||||
out := make([]reminderRow, 0, len(rs))
|
||||
for _, r := range rs {
|
||||
status := r.Status
|
||||
detail := ""
|
||||
if !r.DeliveryBlockedTs.IsZero() {
|
||||
status = "blocked"
|
||||
detail = r.DeliveryBlockedError
|
||||
} else if r.DeliveryAttempts > 0 && !r.NextAttemptTs.IsZero() {
|
||||
detail = "retry " + r.NextAttemptTs.Local().Format("02 Jan 15:04")
|
||||
}
|
||||
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,
|
||||
Status: status,
|
||||
Detail: detail,
|
||||
Text: reminderText(r.Payload),
|
||||
})
|
||||
}
|
||||
@@ -61,13 +71,13 @@ func reminderRows(rs []ipc.Reminder) []reminderRow {
|
||||
}
|
||||
|
||||
func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "reminders") {
|
||||
if !requireCore(w, r, 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)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
||||
"reminders unavailable", fmt.Errorf("list reminders: %w", err))
|
||||
return
|
||||
}
|
||||
renderPage(w, remindersTmpl, map[string]any{"Reminders": reminderRows(reminders)})
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{{range .Reminders}}<tr>
|
||||
<td class=hint>{{.Created}}</td>
|
||||
<td>{{.Fires}}</td>
|
||||
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
||||
<td><span class="badge {{.Status}}">{{.Status}}</span>{{if .Detail}}<div class=hint>{{.Detail}}</div>{{end}}</td>
|
||||
<td class=text-max>{{.Text}}</td>
|
||||
</tr>{{end}}</table></div>
|
||||
{{else}}<div class=empty>
|
||||
|
||||
@@ -31,6 +31,19 @@ func TestReminderRowsUnwrapAndLocalise(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderRowsExposeBlockedDelivery(t *testing.T) {
|
||||
blocked := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
|
||||
rows := reminderRows([]ipc.Reminder{{
|
||||
Status: "pending",
|
||||
Payload: `{"text":"позвонить врачу"}`,
|
||||
DeliveryBlockedTs: blocked,
|
||||
DeliveryBlockedError: "ntfy credentials rejected",
|
||||
}})
|
||||
if len(rows) != 1 || rows[0].Status != "blocked" || rows[0].Detail != "ntfy credentials rejected" {
|
||||
t.Fatalf("blocked reminder is not visible: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// A payload that is not the envelope is his own words, so it is shown as it is.
|
||||
func TestReminderTextKeepsPlainPayload(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want string }{
|
||||
|
||||
+17
-17
@@ -5,7 +5,6 @@ import (
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -41,7 +40,7 @@ type routineView struct {
|
||||
// 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") {
|
||||
if !requireCore(w, r, core, "routines") {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
@@ -54,8 +53,8 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, se
|
||||
}
|
||||
proposed, err := core.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
log.Printf("routines: list: %v", err)
|
||||
http.Error(w, "routines error: "+err.Error(), http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
||||
"routines unavailable", fmt.Errorf("list proposed routines: %w", err))
|
||||
return
|
||||
}
|
||||
renderPage(w, routinesTmpl, struct {
|
||||
@@ -76,44 +75,45 @@ func applyRoutinePost(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI,
|
||||
// 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) {
|
||||
if !stepUpGate(w, r, 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)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemRoutinesChange,
|
||||
"seed failed", fmt.Errorf("seed routine event: %w", err))
|
||||
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)
|
||||
rid, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("id")), 10, 64)
|
||||
if err != nil || rid <= 0 {
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"invalid id", err)
|
||||
return "", false
|
||||
}
|
||||
switch action {
|
||||
case "accept":
|
||||
if !stepUpGate(w, session, requireStepUp) {
|
||||
if !stepUpGate(w, r, 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)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemRoutinesChange,
|
||||
"accept failed", fmt.Errorf("accept routine %d: %w", rid, err))
|
||||
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)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemRoutinesChange,
|
||||
"dismiss failed", fmt.Errorf("dismiss routine %d: %w", rid, err))
|
||||
return "", false
|
||||
}
|
||||
return "dismissed routine", true
|
||||
default:
|
||||
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"unknown action", nil)
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
+6
-4
@@ -159,9 +159,10 @@ func renderPage(w http.ResponseWriter, t *template.Template, data any) {
|
||||
// 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 {
|
||||
func requireCore(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, surface string) bool {
|
||||
if core == nil {
|
||||
http.Error(w, surface+" disabled (no -core)", http.StatusServiceUnavailable)
|
||||
writeProblem(w, r, http.StatusServiceUnavailable, problemCoreUnavailable,
|
||||
surface+" disabled (no -core)", nil)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -169,11 +170,12 @@ func requireCore(w http.ResponseWriter, core ipc.CoreAPI, surface string) bool {
|
||||
|
||||
// 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 {
|
||||
func stepUpGate(w http.ResponseWriter, r *http.Request, session *webauthn.PasskeySession, requireStepUp bool) bool {
|
||||
if stepUpOK(session, requireStepUp) {
|
||||
return true
|
||||
}
|
||||
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||
writeProblem(w, r, http.StatusForbidden, problemStepUpRequired,
|
||||
"step-up required: assert a passkey first", nil)
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
+52
-21
@@ -5,7 +5,6 @@ import (
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -88,7 +87,7 @@ func rowOf(t ipc.Task) taskRow {
|
||||
// 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") {
|
||||
if !requireCore(w, r, core, "tasks") {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
@@ -97,15 +96,14 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
var err error
|
||||
msg, err = applyTaskPost(ctx, core, r)
|
||||
if err != nil {
|
||||
log.Printf("tasks: %v", err)
|
||||
errMsg = err.Error()
|
||||
errMsg = inlineProblem(r, problemCoreChangeFailed, taskPublicMessage(err), err)
|
||||
}
|
||||
}
|
||||
|
||||
all, err := core.ListTasks(ctx, "")
|
||||
if err != nil {
|
||||
log.Printf("tasks: list: %v", err)
|
||||
http.Error(w, "tasks error: "+err.Error(), http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
||||
"tasks unavailable", fmt.Errorf("list tasks: %w", err))
|
||||
return
|
||||
}
|
||||
// Live rows are ordered by the same ranker the spoken list uses, so the page
|
||||
@@ -162,6 +160,39 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
}{msg, errMsg, tasks.Stalls(live, now()), cands, open, resolved, resolvedTotal > len(resolved)})
|
||||
}
|
||||
|
||||
// taskInputError is a form/domain refusal safe to show back to the owner. IPC,
|
||||
// storage and transport errors never use this type and therefore receive the
|
||||
// generic task failure text plus a request reference.
|
||||
type taskInputError struct{ message string }
|
||||
|
||||
func (e *taskInputError) Error() string { return e.message }
|
||||
|
||||
func taskInput(message string) error { return &taskInputError{message: message} }
|
||||
|
||||
func taskInputf(format string, args ...any) error {
|
||||
return &taskInputError{message: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
type taskPartialError struct {
|
||||
public string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *taskPartialError) Error() string { return e.public + ": " + e.err.Error() }
|
||||
func (e *taskPartialError) Unwrap() error { return e.err }
|
||||
|
||||
func taskPublicMessage(err error) string {
|
||||
var input *taskInputError
|
||||
if errors.As(err, &input) {
|
||||
return input.message
|
||||
}
|
||||
var partial *taskPartialError
|
||||
if errors.As(err, &partial) {
|
||||
return partial.public
|
||||
}
|
||||
return "task update failed"
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -170,7 +201,7 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
||||
if action == "add" {
|
||||
text := strings.TrimSpace(r.FormValue("text"))
|
||||
if text == "" {
|
||||
return "", errors.New("empty task text")
|
||||
return "", taskInput("empty task text")
|
||||
}
|
||||
req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: now()}
|
||||
wgt, err := formWeight(r)
|
||||
@@ -198,7 +229,7 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
||||
|
||||
id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
return "", errors.New("invalid id")
|
||||
return "", taskInput("invalid id")
|
||||
}
|
||||
|
||||
if action == "promote" {
|
||||
@@ -214,7 +245,7 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
||||
// 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")
|
||||
return "", taskInput("empty task text")
|
||||
}
|
||||
wgt, err := formWeight(r)
|
||||
if err != nil {
|
||||
@@ -230,9 +261,9 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
||||
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")
|
||||
return "", taskInput("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")
|
||||
return "", taskInput("a resolved task keeps the text it was finished under")
|
||||
default:
|
||||
return "", err
|
||||
}
|
||||
@@ -247,7 +278,7 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
||||
case "drop":
|
||||
status, msg = "dropped", "dropped task"
|
||||
default:
|
||||
return "", fmt.Errorf("unknown action %q", action)
|
||||
return "", taskInputf("unknown action %q", action)
|
||||
}
|
||||
if err := core.SetTaskStatus(ctx, id, status, now(), "tap:web"); err != nil {
|
||||
return "", statusWriteErr(err)
|
||||
@@ -257,7 +288,7 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
||||
|
||||
// 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")
|
||||
var errNoDoneWhen = taskInput("write a definition of done before confirming this candidate")
|
||||
|
||||
// statusWriteErr translates a SetTaskStatus failure into what the page says.
|
||||
func statusWriteErr(err error) error {
|
||||
@@ -281,11 +312,11 @@ func statusWriteErr(err error) error {
|
||||
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")
|
||||
return "", taskInput("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")
|
||||
return "", taskInput("empty task text")
|
||||
}
|
||||
due, err := formDue(r, now())
|
||||
if err != nil {
|
||||
@@ -326,7 +357,7 @@ func promoteCandidate(ctx context.Context, core ipc.CoreAPI, r *http.Request, id
|
||||
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 "", &taskPartialError{public: "task confirmed, but the reminder did not save", err: err}
|
||||
}
|
||||
return "confirmed, and maven will remind you that morning", nil
|
||||
}
|
||||
@@ -343,15 +374,15 @@ func resolveBlocker(ctx context.Context, core ipc.CoreAPI, field string) (string
|
||||
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")
|
||||
return "", taskInput("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)
|
||||
return "", taskInputf("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 "", taskInputf("%q matches %s — say which", name, strings.Join(ref.Candidates, ", "))
|
||||
}
|
||||
return ref.ID, nil
|
||||
}
|
||||
@@ -366,7 +397,7 @@ func formWeight(r *http.Request) (int, error) {
|
||||
}
|
||||
wgt, err := strconv.Atoi(v)
|
||||
if err != nil || wgt < 0 {
|
||||
return 0, fmt.Errorf("bad weight %q", v)
|
||||
return 0, taskInputf("bad weight %q", v)
|
||||
}
|
||||
if wgt > tasks.MaxWeight {
|
||||
wgt = tasks.MaxWeight
|
||||
@@ -383,7 +414,7 @@ func formDue(r *http.Request, now time.Time) (*time.Time, error) {
|
||||
}
|
||||
due, err := time.ParseInLocation("2006-01-02", d, now.Location())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bad due date %q", d)
|
||||
return nil, taskInputf("bad due date %q", d)
|
||||
}
|
||||
return &due, nil
|
||||
}
|
||||
|
||||
@@ -191,6 +191,24 @@ func TestHandleTasksRejectsBadPost(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTasksSanitizesCoreWriteFailure(t *testing.T) {
|
||||
core := &fakeTaskCore{captureErr: fmt.Errorf("sqlite /private/maven.db: key material rejected")}
|
||||
form := url.Values{"action": {"add"}, "text": {"что-то"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
handleTasks(rec, req, core)
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"task update failed", string(problemCoreChangeFailed), "request "} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("sanitized task error missing %q: %s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "/private/maven.db") || strings.Contains(body, "key material") {
|
||||
t.Errorf("task page disclosed the core error: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTasksNoCore(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), nil)
|
||||
|
||||
+21
-16
@@ -3,8 +3,8 @@ package main
|
||||
import (
|
||||
"cmp"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -32,13 +32,13 @@ var toolsTmpl = parsePage("tools", toolsHTML, template.FuncMap{
|
||||
// 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") {
|
||||
if !requireCore(w, r, core, "tools") {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
var msg string
|
||||
if r.Method == http.MethodPost {
|
||||
if !stepUpGate(w, session, requireStepUp) {
|
||||
if !stepUpGate(w, r, session, requireStepUp) {
|
||||
return
|
||||
}
|
||||
action := r.FormValue("action")
|
||||
@@ -49,54 +49,59 @@ func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, sessi
|
||||
cmd := strings.Fields(r.FormValue("cmd"))
|
||||
destructive := r.FormValue("destructive") != ""
|
||||
if name == "" || len(cmd) == 0 {
|
||||
http.Error(w, "name and cmd required", http.StatusBadRequest)
|
||||
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 {
|
||||
log.Printf("tools: enable %q: %v", name, err)
|
||||
http.Error(w, "enable failed: "+err.Error(), http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemToolsChange,
|
||||
"enable failed", fmt.Errorf("enable tool %q: %w", name, err))
|
||||
return
|
||||
}
|
||||
msg = "enabled " + name
|
||||
case "disable":
|
||||
if name == "" {
|
||||
http.Error(w, "name required", http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"name required", nil)
|
||||
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)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemToolsChange,
|
||||
"disable failed", fmt.Errorf("disable tool %q: %w", name, err))
|
||||
return
|
||||
}
|
||||
msg = "disabled " + name
|
||||
case "dismiss":
|
||||
if name == "" {
|
||||
http.Error(w, "name required", http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"name required", nil)
|
||||
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)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemToolsChange,
|
||||
"dismiss failed", fmt.Errorf("dismiss tool %q: %w", name, err))
|
||||
return
|
||||
}
|
||||
msg = "dismissed " + name
|
||||
default:
|
||||
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||
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 {
|
||||
log.Printf("tools: %v", err)
|
||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||
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 {
|
||||
log.Printf("tools: mcp servers: %v", err)
|
||||
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
|
||||
|
||||
+19
-13
@@ -46,24 +46,28 @@ func pushToTalk(pcm []byte) voice.Request {
|
||||
|
||||
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", http.StatusMethodNotAllowed)
|
||||
writeProblem(w, r, http.StatusMethodNotAllowed, problemMethodNotAllowed,
|
||||
"POST only", nil)
|
||||
return
|
||||
}
|
||||
if !stepUpGate(w, session, requireStepUp) {
|
||||
if !stepUpGate(w, r, session, requireStepUp) {
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxPTTAudioBytes))
|
||||
if err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
http.Error(w, "audio exceeds the ten-minute PTT limit", http.StatusRequestEntityTooLarge)
|
||||
writeProblem(w, r, http.StatusRequestEntityTooLarge, problemPayloadTooLarge,
|
||||
"audio exceeds the ten-minute PTT limit", err)
|
||||
return
|
||||
}
|
||||
http.Error(w, "read audio", http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"read audio", fmt.Errorf("read PTT audio: %w", err))
|
||||
return
|
||||
}
|
||||
if len(body) < 4 {
|
||||
http.Error(w, "too short", 400)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"audio too short", nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -72,36 +76,38 @@ func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session
|
||||
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", http.StatusServiceUnavailable)
|
||||
writeProblem(w, r, http.StatusServiceUnavailable, problemVoiceUnavailable,
|
||||
"voice unavailable", fmt.Errorf("dial voice service: %w", err))
|
||||
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)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemVoiceTransport,
|
||||
"voice request failed", fmt.Errorf("write voice request: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
resp, push, err := readOneFrame(tc)
|
||||
if err != nil {
|
||||
log.Printf("ptt read: %v", err)
|
||||
http.Error(w, err.Error(), 500)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemVoiceTransport,
|
||||
"voice response failed", fmt.Errorf("read voice response: %w", err))
|
||||
return
|
||||
}
|
||||
if push != nil {
|
||||
continue
|
||||
}
|
||||
if resp.Error != nil {
|
||||
http.Error(w, resp.Error.Message, 500)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemVoiceResponse,
|
||||
"voice turn failed", fmt.Errorf("voice RPC error: %s", resp.Error.Message))
|
||||
return
|
||||
}
|
||||
var pttResp voice.PushToTalkResp
|
||||
if err := json.Unmarshal(resp.Result, &pttResp); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemVoiceResponse,
|
||||
"voice response failed", fmt.Errorf("decode voice response: %w", err))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "audio/l16;rate=16000;channels=1")
|
||||
|
||||
+18
-14
@@ -92,8 +92,8 @@ 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")
|
||||
if err != nil {
|
||||
log.Printf("webauthn: register begin: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
writeProblem(w, r, http.StatusInternalServerError, problemWebAuthnBegin,
|
||||
"passkey registration could not start", fmt.Errorf("webauthn register begin: %w", err))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -102,7 +102,8 @@ func (h *PasskeyHandle) RegisterBegin(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *PasskeyHandle) RegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
writeProblem(w, r, http.StatusMethodNotAllowed, problemMethodNotAllowed,
|
||||
"POST only", nil)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
@@ -110,7 +111,8 @@ func (h *PasskeyHandle) RegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||
Credential map[string]any `json:"credential"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"invalid registration request", fmt.Errorf("decode webauthn registration: %w", err))
|
||||
return
|
||||
}
|
||||
save := func(id string, publicKey []byte, _ []byte, _ string) error {
|
||||
@@ -118,8 +120,8 @@ func (h *PasskeyHandle) RegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
credID, err := h.rp.FinishRegistration(save, body.Challenge, body.Credential)
|
||||
if err != nil {
|
||||
log.Printf("webauthn: register finish: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemWebAuthnFinish,
|
||||
"passkey registration failed", fmt.Errorf("webauthn register finish: %w", err))
|
||||
return
|
||||
}
|
||||
log.Printf("webauthn: registered credential %s", credID)
|
||||
@@ -140,8 +142,8 @@ func (h *PasskeyHandle) RegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *PasskeyHandle) AssertBegin(w http.ResponseWriter, r *http.Request) {
|
||||
opts, challenge, err := h.rp.AssertionOptions()
|
||||
if err != nil {
|
||||
log.Printf("webauthn: assert begin: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
writeProblem(w, r, http.StatusInternalServerError, problemWebAuthnBegin,
|
||||
"passkey assertion could not start", fmt.Errorf("webauthn assert begin: %w", err))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -150,7 +152,8 @@ func (h *PasskeyHandle) AssertBegin(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
writeProblem(w, r, http.StatusMethodNotAllowed, problemMethodNotAllowed,
|
||||
"POST only", nil)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
@@ -176,7 +179,8 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
|
||||
Explicit bool `json:"explicit"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"invalid assertion request", fmt.Errorf("decode webauthn assertion: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -189,8 +193,8 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
credID, err := h.rp.FinishAssertion(lookup, update, body.Challenge, body.Credential)
|
||||
if err != nil {
|
||||
log.Printf("webauthn: assert finish: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemWebAuthnFinish,
|
||||
"passkey assertion failed", fmt.Errorf("webauthn assert finish: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -201,8 +205,8 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := h.assertFn.AssertStepUp(ctx); err != nil {
|
||||
log.Printf("webauthn: assert step-up: %v", err)
|
||||
http.Error(w, "step-up assertion failed", http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemWebAuthnStepUp,
|
||||
"step-up assertion failed", fmt.Errorf("assert step-up in core: %w", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user