items 5-7: passkey step-up, tools enable/disable, note RAG — end to end

Completes the three in-flight open items and fixes the away-fallthrough bug.

Item 7 — passkey step-up (WebAuthn):
- internal/webauthn: ES256/P-256 register + assert with real ecdsa signature
  verification, minimal CBOR/COSE decode, PasskeySession (L2→L3 on assert,
  decays after TTL). Drop the RS256 offer we can't verify (register-ok/
  assert-fail trap). Verify rpIdHash + UP/UV flags in FinishAssertion — UV is
  the step-up gesture. Round-trip test with negative cases (tampered sig,
  missing UV, wrong origin).
- cmd/mavweb: /auth/passkey enroll+assert page (the only surface that can do
  a WebAuthn gesture) + the four begin/finish endpoints. Without this the
  daemon's PasskeySession swap leaves /tools enable permanently blocked.
- daemon wires PasskeySession as the auth Session + srv.StepUp; policy gates
  MethodAssertStepUp at AuthRead.

Item 5 — tools page: DisableTool through store/ipc/client/wire; /tools grows a
disable action and a link to the passkey page. Lifecycle test.

Item 6 — note RAG: PhraseQuery on the phraser (LLM-composed answer over top-k
notes, raw-notes fallback); IntentQuery routes through it. Stub returns a
deterministic summary.

Item 2 — away-fallthrough: on ErrVoiceNoSession the dispatcher now reroutes
through the AWAY table (sev3→ntfy, sev4→telegram-repeat-til-ack, sev≤2→drop)
instead of silently dropping / mis-routing to the present-list remainder.
Covers DispatchNudge + DispatchReminder. 4 tests.

Also: re-add ProposeTool to CoreAPI (dropped in a comment rewrite), fix
missing imports + a duplicate block left mid-edit, drop dead AssertStepUpFunc,
gitignore /mavcaldav.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kami
2026-07-03 18:41:13 +04:00
parent 36233058dd
commit 6239eca243
21 changed files with 1492 additions and 50 deletions
+10 -8
View File
@@ -47,6 +47,7 @@ import (
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/store"
"github.com/kami/maven/internal/webauthn"
)
func main() {
@@ -114,7 +115,7 @@ func run(args []string) error {
defer phr.Close()
// ----- voice: reactive audio path (TCP listener + stt/router/tts) -----
voiceW, err := wireVoice(cfg, ipc.NewStoreAPI(st))
voiceW, err := wireVoice(cfg, ipc.NewStoreAPI(st), phr)
if err != nil {
return fmt.Errorf("wire voice: %w", err)
}
@@ -169,13 +170,14 @@ func run(args []string) error {
if err != nil {
return fmt.Errorf("ipc listen: %w", err)
}
// auth floor: any same-uid caller is fully trusted (FloorEnrollment +
// FloorSession — L3, step-up satisfied). The cold-start unlock dance and a
// real passkey Session are the open spec items; today the daemon runs
// unlocked — plain sqlite, sqlcipher deferred. FloorSession keeps the floor
// consistent so the authed mavweb /tools page can EnableTool (AuthStepUp)
// against the local socket; the passkey verifier swaps FloorSession later.
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: auth.FloorSession{}}).Check
// auth: Enrolled callers can assert step-up via MethodAssertStepUp (calls
// Session.Assert). After a successful passkey assertion, the session bumps
// to L3 for assertionTTL, enabling AuthStepUp methods (EnableTool).
// FloorEnrollment still trusts same-uid callers; the passkey verifier
// (WebAuthn) gates the session step-up, not the enrollment.
passkeySess := webauthn.NewPasskeySession(5 * time.Minute)
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
srv.StepUp = func(ctx context.Context) error { return passkeySess.Assert(ctx, auth.Scope{}) }
var wg sync.WaitGroup
wg.Add(1)
+20 -21
View File
@@ -59,6 +59,7 @@ import (
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/delivery/voicesink"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/stt"
"github.com/kami/maven/internal/tool"
@@ -106,7 +107,7 @@ func (w *voiceWiring) close() {
//
// When voice is enabled, MUST wire a voicesink into the dispatcher's Voice
// slot using w.sessions (the caller does that — see main.go).
func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI) (*voiceWiring, error) {
func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser) (*voiceWiring, error) {
if cfg.Voice == nil || !cfg.Voice.Enabled {
return nil, nil
}
@@ -187,14 +188,15 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI) (*voiceWiring, error) {
// ----- the handler (the reactive path; closes over stt / tts / router / coreAPI) -----
h := &reactiveHandler{
stt: transcriber,
tts: synthesizer,
router: rtr,
stt: transcriber,
tts: synthesizer,
router: rtr,
embedder: emb,
api: coreAPI,
tools: exec,
api: coreAPI,
tools: exec,
phraser: phr,
replier: voice.NewStubReplier(),
now: time.Now,
now: time.Now,
}
// ----- the server (TCP listener) -----
@@ -219,6 +221,7 @@ type reactiveHandler struct {
embedder router.Embedder // reused for note write/query (same model as the classifier)
api ipc.CoreAPI
tools *tool.Executor
phraser phraser.Phraser
replier voice.Replier
now func() time.Time
@@ -425,22 +428,18 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision)
if len(notes) == 0 || notes[0].Score < queryMinScore {
return "у меня нет заметок по этому вопросу."
}
// Full RAG (phraser-composed) is deferred — this is the browse surface.
// Return a summary of the best match(es) so the user gets context, not just
// one verbatim snippet. The phraser seam in the replier will natural-language
// the results when the LLM-backed Replier swaps in.
if len(notes) == 1 {
return "ты записал: " + notes[0].Text
}
var b strings.Builder
b.WriteString("вот что нашла: ")
texts := make([]string, len(notes))
for i, n := range notes {
if i > 0 {
b.WriteString("; ")
}
b.WriteString(n.Text)
texts[i] = n.Text
}
return b.String()
reply, err := h.phraser.PhraseQuery(ctx, dec.Utterance, texts)
if err != nil {
log.Printf("voice: phrase query: %v", err)
}
if reply == "" {
reply = "вот что я нашла: " + texts[0]
}
return reply
}
return ""
}