From b934571b973936edd53eeb673493d88e427ab3ad Mon Sep 17 00:00:00 2001 From: m3ta-chiron Date: Sun, 18 Oct 2026 21:03:24 +0200 Subject: [PATCH] =?UTF-8?q?chore(sdd):=20handoff=20snapshot=20=E2=80=94=20?= =?UTF-8?q?ledger,=20briefs,=20reports,=20review=20packages=20+=20HANDOFF.?= =?UTF-8?q?md=20for=20cross-machine=20continuation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .scratch/talk-2-0/HANDOFF.md | 29 + .superpowers/sdd/plan/plan-path | 1 + .superpowers/sdd/plan/progress.md | 51 + .../sdd/plan/review-24ceff3..30bd535.diff | 1733 ++++++++ .../sdd/plan/review-30bd535..acfba19.diff | 1286 ++++++ .../sdd/plan/review-3b44605..dc94f45.diff | 3814 +++++++++++++++++ .../sdd/plan/review-8306c6b..ae9f3b6.diff | 145 + .../sdd/plan/review-8351d40..3b44605.diff | 178 + .../sdd/plan/review-ae9f3b6..8351d40.diff | 3739 ++++++++++++++++ .../sdd/plan/review-c8f3902..fa4f193.diff | 663 +++ .../sdd/plan/review-dc94f45..24ceff3.diff | 2712 ++++++++++++ .../sdd/plan/review-fa4f193..8306c6b.diff | 2451 +++++++++++ .superpowers/sdd/plan/task-1-brief.md | 14 + .superpowers/sdd/plan/task-1-report.md | 115 + .superpowers/sdd/plan/task-2-brief.md | 12 + .superpowers/sdd/plan/task-2-report.md | 225 + .superpowers/sdd/plan/task-3-brief.md | 14 + .superpowers/sdd/plan/task-3-report.md | 83 + .superpowers/sdd/plan/task-4-brief.md | 15 + .superpowers/sdd/plan/task-4-report.md | 87 + .superpowers/sdd/plan/task-5-brief.md | 9 + .superpowers/sdd/plan/task-5-report.md | 50 + .superpowers/sdd/plan/task-6-brief.md | 11 + .superpowers/sdd/plan/task-6-report.md | 143 + .superpowers/sdd/plan/task-7-brief.md | 10 + 25 files changed, 17590 insertions(+) create mode 100644 .scratch/talk-2-0/HANDOFF.md create mode 100644 .superpowers/sdd/plan/plan-path create mode 100644 .superpowers/sdd/plan/progress.md create mode 100644 .superpowers/sdd/plan/review-24ceff3..30bd535.diff create mode 100644 .superpowers/sdd/plan/review-30bd535..acfba19.diff create mode 100644 .superpowers/sdd/plan/review-3b44605..dc94f45.diff create mode 100644 .superpowers/sdd/plan/review-8306c6b..ae9f3b6.diff create mode 100644 .superpowers/sdd/plan/review-8351d40..3b44605.diff create mode 100644 .superpowers/sdd/plan/review-ae9f3b6..8351d40.diff create mode 100644 .superpowers/sdd/plan/review-c8f3902..fa4f193.diff create mode 100644 .superpowers/sdd/plan/review-dc94f45..24ceff3.diff create mode 100644 .superpowers/sdd/plan/review-fa4f193..8306c6b.diff create mode 100644 .superpowers/sdd/plan/task-1-brief.md create mode 100644 .superpowers/sdd/plan/task-1-report.md create mode 100644 .superpowers/sdd/plan/task-2-brief.md create mode 100644 .superpowers/sdd/plan/task-2-report.md create mode 100644 .superpowers/sdd/plan/task-3-brief.md create mode 100644 .superpowers/sdd/plan/task-3-report.md create mode 100644 .superpowers/sdd/plan/task-4-brief.md create mode 100644 .superpowers/sdd/plan/task-4-report.md create mode 100644 .superpowers/sdd/plan/task-5-brief.md create mode 100644 .superpowers/sdd/plan/task-5-report.md create mode 100644 .superpowers/sdd/plan/task-6-brief.md create mode 100644 .superpowers/sdd/plan/task-6-report.md create mode 100644 .superpowers/sdd/plan/task-7-brief.md diff --git a/.scratch/talk-2-0/HANDOFF.md b/.scratch/talk-2-0/HANDOFF.md new file mode 100644 index 0000000..9bdac25 --- /dev/null +++ b/.scratch/talk-2-0/HANDOFF.md @@ -0,0 +1,29 @@ +# HANDOFF — talk 2.0, SDD-Lauf 2026-10-18 (Abendstand) + +Für nahtlose Weiterarbeit am Arbeitslaptop (oder hier): dieser Stand ist komplett auf Gitea gepusht (main + feat/v1 + SDD-Arbeitszustand via force-add). + +## Wo der Loop steht + +- **Tasks 1–6: complete & review clean** (Details + aufgeschobene Minors: `.superpowers/sdd/plan/progress.md`) +- **Task 7 (talk wrap): implementiert, 5 Commits `a3424c3..acfba19`** — Dispatch wurde extern unterbrochen, Report-Datei fehlt, Review wurde nachträglich vom Controller angestoßen. **Erste Aktion beim Weitermachen: Ledger-Eintrag „Task 7: review …" lesen.** Falls Review Okay/Approved → complete setzen; falls Befunde → Fix-Loop frisch dispatchen (Implementer-Sessions sind maschinenlokal, NICHT fortsetzbar — neues Dispatch mit Brief + Befunden). +- **Tasks 8–13: noch nicht dispatched.** Reihenfolge/Blocker: siehe `.scratch/talk-2-0/plan.md`. Task 13 ist Nutzer-gated (ElevenLabs-Key-Rotation VOR Löschung von ~/talk.sh). + +## Wie weitermachen (exact) + +1. Repo klonen/pullen: `git@code.m3ta.dev:m3tam3re/talk.git`, Branch `feat/v1` +2. SDD-Skill laden (`subagent-driven-development`), Ledger lesen: `.superpowers/sdd/plan/progress.md` (erste Zeile = plan-Identität; Tasks mit „complete" sind fertig — nicht re-dispatchen) +3. Nächster Task: Brief extrahieren mit `bash ~/.agents/skills/subagent-driven-development/scripts/task-brief .scratch/talk-2-0/plan.md ` — N = erster Task ohne complete-Zeile +4. Subagents auf `zai-coding-plan/glm-5.3#max` (Nutzer-Vorgabe) + +## Umgebungsfakten (dev-relevant) + +- Dev-Host Linux/NixOS, Go 1.26.8; **espeak fehlt, spd-say/speech-dispatcher vorhanden** (osbuiltin-Linux nutzt spd-say) +- Cross-Compile nur mit `CGO_ENABLED=0` (oto v3 purego) — Release-Task 11 muss das pinnen +- **Windows-Test-VM: 192.168.122.115 via WinRM** (Zugangsdaten beim Nutzer) — Task 11 bekommt echte Windows-Laufzeit-Verifikation +- Real-Key-Check (ElevenLabs/OpenAI) noch ausständig — Keys waren nicht in Env; natürlicher Nachtest via Task 8 `talk test`/`doctor` nach Key-Rotation + +## Offene Nutzer-Aktionen + +- [ ] **Push-Auth**: auf neuer Maschine `ssh-add ~/.ssh/gitea` (SSH-Config hat Host code.m3ta.dev → IdentityFile gitea) +- [ ] ElevenLabs-Key rotieren → dann Task 13 (Löschung ~/talk.sh) freigeben +- [ ] nixpkgs-PRs #24/#7 als superseded schließen + m3ta-nixpkgs talk/crunch deprecaten (Vorlage kommt in Task 12) diff --git a/.superpowers/sdd/plan/plan-path b/.superpowers/sdd/plan/plan-path new file mode 100644 index 0000000..b4ed8d0 --- /dev/null +++ b/.superpowers/sdd/plan/plan-path @@ -0,0 +1 @@ +.scratch/talk-2-0/plan.md diff --git a/.superpowers/sdd/plan/progress.md b/.superpowers/sdd/plan/progress.md new file mode 100644 index 0000000..07be3fa --- /dev/null +++ b/.superpowers/sdd/plan/progress.md @@ -0,0 +1,51 @@ +# SDD ledger — plan: .scratch/talk-2-0/plan.md + +Repo: ~/p/talk, Branch feat/v1, Start-BASE: (initial commit auf main) +Model-Politik: alle Subagents auf zai-coding-plan/glm-5.3#max (Nutzer-Vorgabe). + +## Pre-Flight Scan (Tabelle) + +| Paar/Task | Befund | Ruling | +|---|---|---| +| T01 ↔ T02–T11 | T01 erzeugt Modul-Layout, das alle konsumieren | Kein Konflikt; T01 zuerst | +| T02 ↔ T03 | T02 definiert Provider-Interface, T03 erweitert Implementierungen | Sequenziell (Kante gesetzt) — Interface-Signatur in T02 ist Vertrag | +| T04 ↔ T05 | beide berühren internal/queue | Sequenziell; T04 baut Struktur mit Rate-Limit-Platzhalter, T05 verdrahtet | +| T04 ↔ T06 | gleiche HTTP-Oberfläche (/speak) | Sequenziell; T06 erweitert Router, rewrites nicht | +| T07 ↔ T04 | wrap nutzt enqueue-Pfad | Sequenziell; kein Konflikt | +| T08 ↔ T09 | teilen cmd/talk-Dispatch; T09 darf plain-Pfade nicht verändern | Im Brief verankert (TTY-Weiche in derselben Command-Funktion) | +| T11 oto-Cross-Compile | Risiko: CGO-Abhängigkeit bricht statische Targets | Plan verankert Ausweg: build-tag-Plattform-Fallback-Player; Report-Pflicht. Load-bearing für Release | +| T12 greift in AGENTS-Repo | Side effect außerhalb des Worktrees | Nutzer hat Redirect-Stubs in Grilling R4/Q2 explizit freigegeben → dispatch-fähig; nur Branch chore/talk2-migration, kein Push, keine main-Commits | +| T13 talk.sh-Löschung | destruktiv + Key noch nicht rotiert | Parked bis Nutzer Rotation bestätigt. Kein Dispatch | +| T02 espeak-Verfügbarkeit | Dev-Host braucht espeak für OS-TTS-Demo | Faktencheck läuft; falls fehlend: Demo über edge/osbuiltin-Fallback dokumentieren, espeak-Installation bleibt Nutzer-Entscheid | + +Keine plan-mandierten Rubrik-Verstöße gefunden (keine assert-losen Tests, keine Duplikations-Vorgaben). + +## Verlauf + +(befüllt im Lauf) +Task 1: complete (commits c8f3902..fa4f193, review clean; Approved, 0 Critical/Important) +Task 1: minor (deferred): -h-Zweig ungetestet; Version-Test mutiert Package-Var; README-.scratch-Links beim Push prüfen; help-Pseudocommand als additive Surface akzeptiert; "[Flags]"-Kosmetik bei flaglosen Befehlen +Task 1: ⚠️ CI-Runner-Validierung → löst sich beim ersten Push (Gitea-Runner führt Workflow aus) +Task 2: fix round 1/5 (1 Important + 1 Minor addressed, 0 open; commits 8306c6b..ae9f3b6) +Task 2: complete (commits fa4f193..ae9f3b6, review clean nach Fix) +Task 2: minor (deferred): oto-Context-Fehler dauerhaft gecached (Task-4-Thema); WAV-chunk int-Konversion 32-bit-theoretisch; bare talk am Terminal ungetestet +Task 2: Interface-Notiz für Task 3: NewSink(name string, log io.Writer); DirectSpeaker-Erweiterung + ErrNoByteOutput als letzte Kettenstufe berücksichtigen; oto v3.5.0 + BurntSushi/toml v1.6.0 gepinnt; Cross-Compile nur mit CGO_ENABLED=0 (Release-Task merken) +Env-Fakt (Nutzer, 2026-10-18): Windows-Test-VM 192.168.122.115 via WinRM erreichbar (Zugangsdaten vom Nutzer im Chat, bewusst NICHT in Dateien). Ruling: Task 11 erhält echte Windows-Laufzeit-Verifikation (SAPI, Toast, Binary-Smoke) statt nur Cross-Compile; Task 8 doctor darf die VM opportunistisch nutzen. WinRM-Zugriff via ansible/pywinrm zum Zeitpunkt der Nutzung prüfen. +Task 3: implementiert (commits 7a843ae..8351d40), Review ausstehend +Task 3: fix round 1/5 (1 Important addressed, 0 open; commits 8351d40..3b44605) +Task 3: complete (commits ae9f3b6..3b44605, review clean nach Fix) +Task 3: Ruling: Real-Key-Abnahme (plan-mandiert) auf Nutzer-Checkpoint NACH ElevenLabs-Key-Rotation verschoben — Keys nachweislich nicht in Env; Ersatzbeweise (wörtliche Contract-Fakes, realer Edge-Lauf mit 17 kB Audio) stark; natürlicher Nachtest via T8 test/doctor. Kostet falls falsch: ein realer API-Vertrag könnte abweichen — Risiko eingegrenzt. +Task 3: minor (deferred): parseInterspersed bricht --Shielding; io.ReadAll unbegrenzt; unbekannte Config-Sections ohne Warnung; edgeClockSkew package-global; Kommentar-Typos +Task 4-Kontext: Chain (Speak/Synthesize/SpeakDirect, walk einmalig), Player-Interface strukturell == audio.Sink, injizierbar Getenv/Log/Player — Daemon MUSS Chain unverändert wiederverwenden +Task 4: complete (commits 3b44605..dc94f45, review clean; Approved, 0 Critical/Important) +Task 4: minor (deferred): Stale-Lock-Remove-Race (rename-before-remove/PID-Check); waitForItem ohne Timeout; Lost-202-Doppelansage (Idempotien-Key später); setMute lumped errors; Timing-Margins CI-Flake-Risiko; Port-in-use-Pfad ungetestet +Task 4-Kontext für Task 5: RateLimiter-Struktur in internal/queue/rate.go wartet auf Verdrahtung (Seam dokumentiert); /status-Felder erweiterbar; queue rank/loop unverändert lassen +Task 5: complete (commits dc94f45..24ceff3, review clean; Approved, 0 Critical/Important) +Task 5: minor (deferred): quiet_mode TOML-Validierung fehlt (env hat sie); Worker-zeitige Verwurf/toast-Zweige ungetestet; TestRuhezeitToast schwache Log-Assertion ("ersetzt" statt "toast"); Timing-Margins CI-Flake-Risiko; Degraded respektiert nur Mute (akzeptiert, dokumentiert) +Task 6-Kontext: api/server.go Router + types.go StatusResponse (additive Felder via omitempty); Gesetz-Präzedenz mute > now > quiet > rate in rate.go decide(); Prozess-Test-Harness test/ mit Fake-Provider +Task 6: complete (commits 24ceff3..30bd535, review clean; Approved, 0 Critical/Important; nach Resume nach externer Unterbrechung) +Task 6: minor (deferred): Preflight prüft ACRM nicht; Allow-Header-Inkonsistenz GET /mcp; Origin-Syntax-Validierung; MaxBody-Fehlercode -32700; voice im Tool-Schema nicht beworben +Task 6-Kontext: admitSpeak = gemeinsame Aufnahme (Validierung → Mute → Enqueue); MCP + /speak strukturell paritätisch; Remote origin gesetzt (git@code.m3ta.dev) — PUSH BLOCKIERT bis Nutzer ssh-add ~/.ssh/gitea ODER Token; Repo via HTTPS öffentlich lesbar +Task 6: Env-Fakt: Remote https://code.m3ta.dev/m3tam3re/talk vom Nutzer angelegt +Task 7: implementiert (commits a3424c3..acfba19, 5 Conventional) — Dispatch extern unterbrochen NACH Commits, VOR Report/Return; Report-Datei fehlt. Review nachträglich angestoßen (ohne Implementer-Report, Diff-vs-Brief). +Task 7: Handoff-Snapshot für Gitea-Push erstellt (HANDOFF.md + force-add dieses Workspaces) diff --git a/.superpowers/sdd/plan/review-24ceff3..30bd535.diff b/.superpowers/sdd/plan/review-24ceff3..30bd535.diff new file mode 100644 index 0000000..2cdb3d0 --- /dev/null +++ b/.superpowers/sdd/plan/review-24ceff3..30bd535.diff @@ -0,0 +1,1733 @@ +# Review package: 24ceff3..HEAD + +## Commits +30bd535 docs(readme): CORS-Konfiguration und MCP-Endpoint mit curl-Handshake dokumentiert +150c374 test: Prozessgrenzen-Tests für CORS-Tür und MCP-Handshake +a577597 feat(api): MCP-Endpoint POST /mcp — schlankes eigenes JSON-RPC-Subset mit speak-Tool +156c20d feat(api): CORS-Tür für POST /speak — OPTIONS-Preflight und ACAO aus Config +cef6e6b feat(config): CORS-Erlaubnisliste server.allowed_origins (TALK_ALLOWED_ORIGINS) + +## Files changed + README.md | 63 ++++++- + cmd/talkd/main.go | 13 +- + internal/api/cors.go | 78 +++++++++ + internal/api/doc.go | 11 +- + internal/api/mcp.go | 371 +++++++++++++++++++++++++++++++++++++++++ + internal/api/server.go | 96 ++++++++--- + internal/api/types.go | 11 +- + internal/config/config.go | 44 +++++ + internal/config/config_test.go | 103 ++++++++++++ + test/cors_test.go | 195 ++++++++++++++++++++++ + test/harness_test.go | 72 +++++++- + test/mcp_test.go | 300 +++++++++++++++++++++++++++++++++ + 12 files changed, 1318 insertions(+), 39 deletions(-) + +## Diff +README.md --- 1/3 --- Text + 33 `talkd`, siehe unten), `mute`, `unm 33 `talkd`, siehe unten), `mute`, `unm + .. ute`, `status`, `cancel`. Die übrig .. ute`, `status`, `cancel`. Die übrig + .. en .. en + 34 Befehle sind Stubs und melden `not 34 Befehle sind Stubs und melden `not + .. implemented yet` (Exit-Code ≠ 0). .. implemented yet` (Exit-Code ≠ 0). + 35 35 + 36 ## Daemon talkd (Task 04 + 05) 36 ## Daemon talkd (Task 04–06) + 37 37 + 38 `talkd` besitzt die Ansage-Queue un 38 `talkd` besitzt die Ansage-Queue un + .. d spricht seriell ab — genau eine A .. d spricht seriell ab — genau eine A + .. nsage .. nsage + 39 gleichzeitig, Reihenfolge `now` > ` 39 gleichzeitig, Reihenfolge `now` > ` + .. high` > `normal` > `low` (FIFO je .. high` > `normal` > `low` (FIFO je + +README.md --- 2/3 --- Text + 43 | Endpunkt | Wirkung | 43 | Endpunkt | Wirkung | + 44 | --- | --- | 44 | --- | --- | + 45 | `POST /speak` | `{text, priority? 45 | `POST /speak` | `{text, priority? + .. , provider?, voice?, lang?, now?}` .. , provider?, voice?, lang?, now?}` + .. → `202 {"id":"…"}`; bei Mute: `202 .. → `202 {"id":"…"}`; bei Mute: `202 + .. {"id":"","muted":true}`; bei Gesetz .. {"id":"","muted":true}`; bei Gesetz + .. -Verwurf: `202 {"id":"","discarded" .. -Verwurf: `202 {"id":"","discarded" + .. :"rate_limit"\|"quiet_hours"}` (+ ` .. :"rate_limit"\|"quiet_hours"}` (+ ` + .. "toast":true` im Toast-Modus) — ver .. "toast":true` im Toast-Modus) — ver + .. worfen, dokumentiertes Verhalten | .. worfen, dokumentiertes Verhalten | + 46 | `GET /status` | `{"version", "que 46 | `OPTIONS /speak` | CORS-Preflight + .. ue", "queue_ids", "current", "muted .. → `204` mit Freigabe-Headern, wenn + .. ", "provider", "rate_limit": {"inte .. die `Origin` in `server.allowed_or + .. rval", "allow_high_burst", "next_sl .. igins` steht (siehe unten); sonst ` + .. ot"}, "quiet_hours": {"window", "ac .. 204` ohne CORS-Header | + .. tive", "mode", "until"}}` | .. + .. 47 | `GET /status` | `{"version", "que + .. .. ue", "queue_ids", "current", "muted + .. .. ", "provider", "rate_limit": {"inte + .. .. rval", "allow_high_burst", "next_sl + .. .. ot"}, "quiet_hours": {"window", "ac + .. .. tive", "mode", "until"}, "mcp": tru + .. .. e, "cors": [origins]}` | + 47 | `POST /cancel` | bricht die laufe 48 | `POST /cancel` | bricht die laufe + .. nde Ansage ab + leert die Queue → ` .. nde Ansage ab + leert die Queue → ` + .. 200 {"cancelled":n}` | .. 200 {"cancelled":n}` | + 48 | `POST /mute` / `POST /unmute` | p 49 | `POST /mute` / `POST /unmute` | p + .. ersistenter Mute-State → `200 {"mut .. ersistenter Mute-State → `200 {"mut + .. ed":bool}` | .. ed":bool}` | + .. 50 | `POST /mcp` | MCP (Streamable HTT + .. .. P): JSON-RPC `initialize`, `notific + .. .. ations/initialized`, `ping`, `tools + .. .. /list`, `tools/call` — ein Tool `sp + .. .. eak` (siehe unten) | + .. 51 | `GET /mcp` | `405` — talkd bietet + .. .. keine server-initiierten Streams a + .. .. n | + 49 52 + 50 - **CLI-Verhalten:** `talk say` (un 53 - **CLI-Verhalten:** `talk say` (un + .. d `talk "Text"`) erkennt den Daemon .. d `talk "Text"`) erkennt den Daemon + .. via .. via + 51 `GET /status`. Läuft er: enqueue 54 `GET /status`. Läuft er: enqueue + .. + sofortige Rückkehr (non-blocking, .. + sofortige Rückkehr (non-blocking, + +README.md --- 3/3 --- Text +105 108 erlaubt), nächster slot …` und `ruhezeit: 18:00-08:00 (modus discard, +106 109 aktiv bis 08:00)`. +107 110 +... 111 ### CORS-Tür + MCP-Endpoint (Task 06) +... 112 +... 113 **CORS** gilt bewusst nur für `POST /speak` — genau die eine Tür, die eine +... 114 spätere Browser-Anbindung (z. B. LibreChat) braucht; Steuer-Endpunkte +... 115 bleiben browserseitig unerreicht. Die Erlaubnis ist eine exakte Origin- +... 116 Liste; `"*"` ist erlaubt, aber nicht Default; **leere Liste (Default) = +... 117 gar keine CORS-Header = der Browser blockt**: +... 118 +... 119 ```toml +... 120 [server] +... 121 allowed_origins = ["https://chat.az-gruppe.com"] # Env TALK_ALLOWED_ORIGINS +... 122 ``` +... 123 +... 124 - `OPTIONS /speak` (Preflight) von gelisteter Origin → `204` mit +... 125 `Access-Control-Allow-Origin` (die Origin bzw. `*`), `-Methods: POST, +... 126 OPTIONS`, `-Headers: Content-Type`, `Max-Age: 600`. +... 127 - Echter `POST /speak` mit gelisteter `Origin` trägt denselben ACAO-Header. +... 128 - Ungelistete Origins bekommen saubere `204`/`202` **ohne** CORS-Header — +... 129 die Blockade macht der Browser. +... 130 +... 131 **MCP** (`POST /mcp`, Streamable HTTP): schlanke eigene Implementierung +... 132 des JSON-RPC-Subsets ohne SDK — `initialize` (angefragte +... 133 `protocolVersion` wird geechot; Ergebnis `protocolVersion`, +... 134 `capabilities: {tools:{}}`, `serverInfo`), `notifications/initialized` +... 135 (Notification → `202` ohne Body), `ping`, `tools/list`, +... 136 `tools/call`. Der Server ist zustandslos (keine Session-IDs) und pusht +... 137 nichts (daher `GET /mcp` → `405`). Ein Tool: +... 138 +... 139 ```json +... 140 {"name": "speak", +... 141 "inputSchema": {"type": "object", +... 142 "properties": {"text": {"type": "string"}, +... 143 "priority": {"enum": ["low","normal","high"]}, +... 144 "provider": {"type": "string"}, +... 145 "lang": {"enum": ["de","en"]}, +... 146 "now": {"type": "boolean"}}, +... 147 "required": ["text"]}} +... 148 ``` +... 149 +... 150 `tools/call` fährt exakt denselben Aufnahme-Pfad wie `POST /speak` +... 151 (inklusive Mute, Rate-Limit, Ruhezeiten). Ergebnis ist immer Text-Content: +... 152 `"queued: "` bei Aufnahme; `muted`/`discarded: ` mit Erklärung +... 153 — bewusst `isError: false`, denn ein Verwurf ist das korrekte +... 154 dokumentierte Ergebnis, kein Protokollfehler. Validierungsfehler +... 155 (fehlender Text, ungültige Priorität, unbekanntes Tool) sind dagegen +... 156 JSON-RPC-Fehler (`-32602`). Handshake als curl-Sequenz: +... 157 +... 158 ```sh +... 159 curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \ +... 160 -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' +... 161 curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \ +... 162 -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' +... 163 curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \ +... 164 -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"speak","arguments":{"text":"Build fertig"}}}' +... 165 ``` +... 166 +108 167 ### Provider-Kette (Task 03) +109 168 + +cmd/talkd/main.go --- 1/3 --- Go + 5 // Default 1 Ansage / 30 s; low übe 5 // Default 1 Ansage / 30 s; low übe + . r Budget wird verworfen, normal par . r Budget wird verworfen, normal par + . kt, . kt, + 6 // high darf mit queue.allow_high_b 6 // high darf mit queue.allow_high_b + . urst verstoßen) plus Ruhezeiten . urst verstoßen) plus Ruhezeiten + 7 // (quiet_hours/quiet_mode; now:tru 7 // (quiet_hours/quiet_mode; now:tru + . e bricht bewusst aus beiden aus). . e bricht bewusst aus beiden aus). + 8 // CORS/MCP folgen in Task 06. 8 // Task 06 ergänzt die CORS-Tür für + . . POST /speak (server.allowed_origin + . . s / + . 9 // TALK_ALLOWED_ORIGINS; leer = kei + . . n CORS) und den MCP-Endpoint POST / + . . mcp + . 10 // (Streamable HTTP, ein speak-Tool + . .. ). + 9 package main 11 package main + 10 12 + 11 import ( 13 import ( + +cmd/talkd/main.go --- 2/3 --- Go +120 122 Mute: mute, +121 123 Version: versionString(), +122 124 KnownProviders: provider.Names(), +... 125 AllowedOrigins: cfg.Server.AllowedOrigins, +123 126 Provider: func() string { +124 127 if cur, ok := q.Current(); ok { +125 128 return resolveProviderName(baseChain, cur) + +cmd/talkd/main.go --- 3/3 --- Go +139 srv := &http.Server{Handler: ha 142 srv := &http.Server{Handler: ha +... ndler, ReadHeaderTimeout: 5 * time. ... ndler, ReadHeaderTimeout: 5 * time. +... Second} ... Second} +140 errCh := make(chan error, 1) 143 errCh := make(chan error, 1) +141 go func() { errCh <- srv.Serve( 144 go func() { errCh <- srv.Serve( +... ln) }() ... ln) }() +... 145 corsLog := "zu (kein CORS)" +... 146 if len(cfg.Server.AllowedOrigin +... ... s) > 0 { +... 147 corsLog = fmt.Sprintf("%v", +... ... cfg.Server.AllowedOrigins) +... 148 } +142 log.Printf("listening on http:/ 149 log.Printf("listening on http:/ +... /%s (version %s, audio sink %s, mut ... /%s (version %s, audio sink %s, mut +... e=%v, rate-limit %s, high-burst=%v, ... e=%v, rate-limit %s, high-burst=%v, +... ruhezeit=%q modus %s)", ... ruhezeit=%q modus %s, cors=%s, mcp +... ... =/mcp)", +143 addr, versionString(), sink 150 addr, versionString(), sink +... .Name(), mute.Muted(), ... .Name(), mute.Muted(), +144 cfg.Queue.RateLimit, cfg.Qu 151 cfg.Queue.RateLimit, cfg.Qu +... eue.AllowHighBurst, cfg.QuietHours, ... eue.AllowHighBurst, cfg.QuietHours, +... cfg.QuietMode) ... cfg.QuietMode, corsLog) +145 152 +146 sig := make(chan os.Signal, 1) 153 sig := make(chan os.Signal, 1) +147 signal.Notify(sig, os.Interrupt 154 signal.Notify(sig, os.Interrupt +... , syscall.SIGTERM) ... , syscall.SIGTERM) + +internal/api/cors.go --- Go + 1 package api + 2 + 3 import ( + 4 "net/http" + 5 ) + 6 + 7 // CORS-Tür für POST /speak (Task 06) — und NUR für diesen Endpunkt: Ein + 8 // Browser (z. B. eine spätere LibreChat-Integration) darf genau die Ansage- + 9 // Schnittstelle cross-origin rufen, keine Steuer-Endpunkte. Die Erlaubnis +10 // kommt ausschließlich aus der Config-Liste server.allowed_origins +11 // (Env TALK_ALLOWED_ORIGINS): exakte Origins wie +12 // "https://chat.az-gruppe.com"; der Eintrag "*" erlaubt jede Origin +13 // ausdrücklich. Bewusster Default: LEER — dann sendet der Daemon gar keine +14 // CORS-Header und der Browser blockt (Tür zu, bis sie konfiguriert wird). +15 // +16 // Ungelistete Origins bekommen bewusst trotzdem 204/202 ohne CORS-Header: +17 // HTTP-seitig ist die Anfrage sauber, die Blockade macht der Browser (so +18 // verrät der Daemon nichts über seine Erlaubnisliste). +19 +20 // CORS-Antwort-Header (Namen zentral, damit Preflight und echter POST +21 // dieselben setzen). +22 const ( +23 headerAllowOrigin = "Access-Control-Allow-Origin" +24 headerAllowMethods = "Access-Control-Allow-Methods" +25 headerAllowHeaders = "Access-Control-Allow-Headers" +26 headerMaxAge = "Access-Control-Max-Age" +27 headerVary = "Vary" +28 ) +29 +30 // preflightAge ist die Preflight-Cache-Dauer: 10 Minuten reichen für eine +31 // Chat-Session und halten den OPTIONS-Chatter klein. +32 const preflightAge = "600" +33 +34 // corsACAOLiefert den Wert für Access-Control-Allow-Origin: bei Wildcard- +35 // Konfiguration das literale "*", sonst die exakt gematchte Origin (die +36 // identisch mit dem Config-Eintrag ist). Leer heißt: nicht erlaubt. +37 func (h *handler) corsACAO(r *http.Request) string { +38 origin := r.Header.Get("Origin") +39 if origin == "" { +40 return "" +41 } +42 for _, allowed := range h.allowedOrigins { +43 if allowed == "*" { +44 return "*" +45 } +46 if allowed == origin { +47 return allowed +48 } +49 } +50 return "" +51 } +52 +53 // setSpeakCors setzt die CORS-Header auf echten POST-/speak-Antworten, +54 // wenn die Anfrage-Origin erlaubt ist. Vary: Origin steht immer (auch bei +55 // geschlossener Tür), damit Caches die Antwort nie fälschlich origin- +56 // übergreifend wiederverwenden. +57 func (h *handler) setSpeakCors(w http.ResponseWriter, r *http.Request) { +58 w.Header().Add(headerVary, "Origin") +59 if acao := h.corsACAO(r); acao != "" { +60 w.Header().Set(headerAllowOrigin, acao) +61 } +62 } +63 +64 // speakPreflight behandelt OPTIONS /speak (CORS-Preflight): 204 mit den +65 // Freigabe-Headern bei gelisteter Origin, 204 ohne jegliche CORS-Header +66 // sonst (ungelistet, fehlend oder Tür komplett zu) — der Browser verwirft +67 // den eigentlichen Request, während der Daemon für Nicht-Browser-Clients +68 // erreichbar bleibt. +69 func (h *handler) speakPreflight(w http.ResponseWriter, r *http.Request) { +70 w.Header().Add(headerVary, "Origin") +71 if acao := h.corsACAO(r); acao != "" { +72 w.Header().Set(headerAllowOrigin, acao) +73 w.Header().Set(headerAllowMethods, "POST, OPTIONS") +74 w.Header().Set(headerAllowHeaders, "Content-Type") +75 w.Header().Set(headerMaxAge, preflightAge) +76 } +77 w.WriteHeader(http.StatusNoContent) +78 } +79 + +internal/api/doc.go --- Go +1 // Package api definiert die HTTP-Ob 1 // Package api definiert die HTTP-Ob +. erfläche des Daemons auf 127.0.0.1: . erfläche des Daemons auf 127.0.0.1: +2 // POST /speak (enqueue, 202 {id}; b 2 // POST /speak (enqueue, 202 {id}; b +. ei Mute 202 {muted:true}; bei Gesetz . ei Mute 202 {muted:true}; bei Gesetz +. - . - +3 // Verwurf 202 {discarded:"rate_limi 3 // Verwurf 202 {discarded:"rate_limi +. t"|"quiet_hours"} — Task 05), . t"|"quiet_hours"} — Task 05), +4 // GET /status (inkl. Rate-Limit- un 4 // GET /status (inkl. Gesetz-State, +. d Ruhezeit-State), POST /cancel, . mcp/cors — Task 06), POST /cancel, +5 // POST /mute, POST /unmute. CORS fü 5 // POST /mute, POST /unmute. Task 06 +. r /speak und der MCP-Endpoint . ergänzt die CORS-Tür für POST /spea +. . k +. 6 // (OPTIONS-Preflight + ACAO aus ser +. . ver.allowed_origins, siehe cors.go) +. 7 // und den MCP-Endpoint POST /mcp al +. . s schlankes eigenes JSON-RPC-Subset +. 8 // (initialize, notifications/initia +. . lized, tools/list, tools/call mit de +. . m +6 // (Streamable HTTP) folgen in Task 9 // speak-Tool — siehe mcp.go). Der C +. 06. Der Client-Teil ist die CLI-Seit . lient-Teil ist die CLI-Seite derselb +. e . en +7 // derselben Vertragsschnittstelle. 10 // Vertragsschnittstelle. +8 package api 11 package api + +internal/api/mcp.go --- Go + 1 package api + 2 + 3 import ( + 4 "encoding/json" + 5 "io" + 6 "net/http" + 7 "strconv" + 8 "strings" + 9 + 10 "code.m3ta.dev/m3tam3re/talk/internal/queue" + 11 ) + 12 + 13 // MCP-Endpoint (Task 06): Streamable HTTP auf POST /mcp — eine eigene, + 14 // schlanke Implementierung des JSON-RPC-2.0-Subsets, das MCP tatsächlich + 15 // braucht (bewusst KEIN SDK-Dependency; der Protokollumfang hier ist vier + 16 // Methoden plus ping). Semantik nach MCP 2025-06-18, mit zwei + 17 // dokumentierten Vereinfachungen, die der Transport hergibt: + 18 // + 19 // - Zustandslos: Jeder POST ist unabhängig; es gibt keine Session-IDs + 20 // (Mcp-Session-Id wird ignoriert) und damit auch keine Durchsetzung + 21 // der Initialize-vor-allem-Regel — ein curl kann tools/call direkt + 22 // rufen. MCP-Clients stört das nicht: Sie fahren den Handshake + 23 // freiwillig. + 24 // - Keine server-initiierten Streams: talkd pusht nie (die Tool-Liste + 25 // ist statisch). GET /mcp antwortet daher 405 — die vom Streamable- + 26 // HTTP-Transport vorgesehene Antwort für Server ohne SSE-Stream. + 27 // + 28 // Antworten: Requests → 200 application/json (JSON-RPC-Fehlerobjekte + 29 // ebenfalls 200 — der HTTP-Teil war ja erfolgreich); Notifications → 202 + 30 // ohne Body (JSON-RPC 2.0). Batches (Arrays) sind seit 2025-06-18 nicht + 31 // mehr Teil von MCP → -32600. + 32 + 33 // mcpProtocolVersion ist die Version, die talkd selbst spricht, wenn der + 34 // Client keine angibt (initialize ohne protocolVersion). Angefragte + 35 // Versionen werden akzeptiert und zurückgegeben (Echo) — inhaltlich + 36 // implementiert ist der oben dokumentierte 2025-06-18-Umfang. + 37 const mcpProtocolVersion = "2025-06-18" + 38 + 39 // JSON-RPC-2.0-Fehlercodes (benutztes Subset). + 40 const ( + 41 rpcParseError = -32700 + 42 rpcInvalidRequest = -32600 + 43 rpcMethodNotFound = -32601 + 44 rpcInvalidParams = -32602 + 45 rpcInternalError = -32603 + 46 ) + 47 + 48 // mcpServerName ist der serverInfo-Name im Initialize-Ergebnis. + 49 const mcpServerName = "talkd" + 50 + 51 // jsonRPCRequest ist eine eingehende JSON-RPC-Nachricht. ID als + 52 // RawMessage: So bleibt zwischen „Feld fehlt" (Notification) und id:0 + 53 // bzw. id:string unterscheidbar. + 54 type jsonRPCRequest struct { + 55 JSONRPC string `json:"jsonrpc"` + 56 ID json.RawMessage `json:"id"` + 57 Method string `json:"method"` + 58 Params json.RawMessage `json:"params"` + 59 } + 60 + 61 // jsonRPCResponse ist eine ausgehende JSON-RPC-Nachricht. Genau eines aus + 62 // Result/Error ist gesetzt. ID ist nie weggelassen — bei Parse-Fehlern + 63 // ohne bekannte ID null (JSON-RPC 2.0). + 64 type jsonRPCResponse struct { + 65 JSONRPC string `json:"jsonrpc"` + 66 ID json.RawMessage `json:"id"` + 67 Result any `json:"result,omitempty"` + 68 Error *rpcError `json:"error,omitempty"` + 69 } + 70 + 71 // rpcError ist das Fehlerobjekt einer JSON-RPC-Antwort. + 72 type rpcError struct { + 73 Code int `json:"code"` + 74 Message string `json:"message"` + 75 } + 76 + 77 // mcpInitializeParams sind die relevanten Teile der initialize-Parameter + 78 // (Client-Capabilities interessieren talkd nicht — Tools genügen). + 79 type mcpInitializeParams struct { + 80 ProtocolVersion string `json:"protocolVersion"` + 81 } + 82 + 83 // mcpServerInfo beschreibt den Server im Initialize-Ergebnis. + 84 type mcpServerInfo struct { + 85 Name string `json:"name"` + 86 Version string `json:"version"` + 87 } + 88 + 89 // mcpCapabilities meldet im Initialize-Ergebnis, was talkd anbietet: + 90 // Tools (speak) — sonst nichts. + 91 type mcpCapabilities struct { + 92 Tools map[string]any `json:"tools"` + 93 } + 94 + 95 // mcpInitializeResult ist das Ergebnis von initialize: die (geechote) + 96 // Protokollversion, die Fähigkeiten und die Server-Info — 2025-06-18-Form. + 97 type mcpInitializeResult struct { + 98 ProtocolVersion string `json:"protocolVersion"` + 99 Capabilities mcpCapabilities `json:"capabilities"` +100 ServerInfo mcpServerInfo `json:"serverInfo"` +101 } +102 +103 // mcpToolsListResult ist das Ergebnis von tools/list: genau ein Tool +104 // (speak); ohne nextCursor (die Liste passt immer in eine Seite). +105 type mcpToolsListResult struct { +106 Tools []mcpTool `json:"tools"` +107 } +108 +109 // mcpTool ist ein Tool-Eintrag in tools/list. +110 type mcpTool struct { +111 Name string `json:"name"` +112 Description string `json:"description"` +113 InputSchema mcpSchema `json:"inputSchema"` +114 } +115 +116 // mcpSchema ist das JSON-Schema-Objekt eines Tool-Inputs. +117 type mcpSchema struct { +118 Type string `json:"type"` +119 Properties map[string]mcpProperty `json:"properties"` +120 Required []string `json:"required"` +121 } +122 +123 // mcpProperty ist eine einzelne Schema-Property. +124 type mcpProperty struct { +125 Type string `json:"type"` +126 Description string `json:"description,omitempty"` +127 Enum []string `json:"enum,omitempty"` +128 } +129 +130 // mcpToolCallParams sind die Parameter von tools/call. +131 type mcpToolCallParams struct { +132 Name string `json:"name"` +133 Arguments json.RawMessage `json:"arguments"` +134 } +135 +136 // mcpContent ist ein Content-Block eines Tool-Ergebnisses (hier: Text). +137 type mcpContent struct { +138 Type string `json:"type"` +139 Text string `json:"text"` +140 } +141 +142 // mcpToolResult ist das Ergebnis von tools/call. isError:false (weggelassen) +143 // auch bei muted/discarded — das ist ein ERGEBNIS der Aufnahme, kein +144 // Protokollfehler (bewusste Entscheidung, siehe speakToolResult). +145 type mcpToolResult struct { +146 Content []mcpContent `json:"content"` +147 IsError bool `json:"isError,omitempty"` +148 } +149 +150 // serveMCP behandelt POST /mcp: eine JSON-RPC-Nachricht pro Request. +151 func (h *handler) serveMCP(w http.ResponseWriter, r *http.Request) { +152 body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes)) +153 if err != nil { +154 writeRPCError(w, nil, rpcParseError, "Body lesen: "+err.Error()) +155 return +156 } +157 if strings.HasPrefix(strings.TrimLeft(string(body), " \t\r\n"), "[") { +158 writeRPCError(w, nil, rpcInvalidRequest, +159 "Batch-Requests werden nicht unterstützt (MCP 2025-06-18 hat Batches entfernt) — eine Nachricht pro POST") +160 return +161 } +162 +163 var req jsonRPCRequest +164 if err := json.Unmarshal(body, &req); err != nil { +165 writeRPCError(w, nil, rpcParseError, "ungültiges JSON: "+err.Error()) +166 return +167 } +168 if req.JSONRPC != "2.0" { +169 writeRPCError(w, nil, rpcInvalidRequest, +170 `jsonrpc muss "2.0" sein (erhalten: `+strconv.Quote(req.JSONRPC)+`)`) +171 return +172 } +173 if isNotification(req.ID) { +174 // Notifications (notifications/initialized, …) bekommen nie eine +175 // JSON-RPC-Antwort — nur den 202-Vermerk des Transports. Unbekannte +176 // Notifications werden still ignoriert (JSON-RPC 2.0). +177 w.WriteHeader(http.StatusAccepted) +178 return +179 } +180 +181 switch req.Method { +182 case "initialize": +183 h.mcpInitialize(w, req) +184 case "notifications/initialized": +185 // Mit ID gesendet: kein regulärer Client — die korrekte Antwort +186 // ist ein leeres Ergebnis, kein Fehler. +187 writeRPCResult(w, req.ID, struct{}{}) +188 case "ping": +189 writeRPCResult(w, req.ID, struct{}{}) +190 case "tools/list": +191 writeRPCResult(w, req.ID, mcpToolsListResult{Tools: []mcpTool{speakTool()}}) +192 case "tools/call": +193 h.mcpToolsCall(w, req) +194 default: +195 writeRPCError(w, req.ID, rpcMethodNotFound, +196 "Methode "+strconv.Quote(req.Method)+" ist nicht implementiert (verfügbar: initialize, notifications/initialized, ping, tools/list, tools/call)") +197 } +198 } +199 +200 // mcpGet behandelt GET /mcp: 405 mit Allow — talkd bietet keine +201 // server-initiierten Streams an (die dafür vorgesehene Streamable-HTTP- +202 // Antwort), Tools-Änderungen gibt es nicht. +203 func (h *handler) mcpGet(w http.ResponseWriter, r *http.Request) { +204 w.Header().Set("Allow", "POST, OPTIONS") +205 w.WriteHeader(http.StatusMethodNotAllowed) +206 } +207 +208 // mcpInitialize behandelt initialize: Die angefragte Protokollversion wird +209 // geechot (akzeptiert) — leer → die eigene (2025-06-18). Capabilities: +210 // Tools; serverInfo: talkd + Daemon-Version. +211 func (h *handler) mcpInitialize(w http.ResponseWriter, req jsonRPCRequest) { +212 var params mcpInitializeParams +213 if len(req.Params) > 0 { +214 if err := json.Unmarshal(req.Params, ¶ms); err != nil { +215 writeRPCError(w, req.ID, rpcInvalidParams, "initialize-params: "+err.Error()) +216 return +217 } +218 } +219 version := params.ProtocolVersion +220 if version == "" { +221 version = mcpProtocolVersion +222 } +223 writeRPCResult(w, req.ID, mcpInitializeResult{ +224 ProtocolVersion: version, +225 Capabilities: mcpCapabilities{Tools: map[string]any{}}, +226 ServerInfo: mcpServerInfo{Name: mcpServerName, Version: h.version}, +227 }) +228 } +229 +230 // mcpToolsCall behandelt tools/call: genau ein Tool (speak), dessen +231 // Argumente denselben Aufnahme-Pfad wie POST /speak fahren — inklusive +232 // Mute und Gesetz (Rate-Limit/Ruhezeit). +233 func (h *handler) mcpToolsCall(w http.ResponseWriter, req jsonRPCRequest) { +234 var params mcpToolCallParams +235 if err := json.Unmarshal(req.Params, ¶ms); err != nil { +236 writeRPCError(w, req.ID, rpcInvalidParams, "tools/call-params: "+err.Error()) +237 return +238 } +239 if params.Name != "speak" { +240 writeRPCError(w, req.ID, rpcInvalidParams, +241 "unbekanntes Tool "+strconv.Quote(params.Name)+" (verfügbar: speak)") +242 return +243 } +244 +245 args := params.Arguments +246 if len(args) == 0 { +247 args = json.RawMessage("{}") +248 } +249 var speakReq SpeakRequest +250 if err := json.Unmarshal(args, &speakReq); err != nil { +251 writeRPCError(w, req.ID, rpcInvalidParams, "arguments: "+err.Error()) +252 return +253 } +254 +255 adm := h.admitSpeak(speakReq) +256 if adm.status != http.StatusAccepted { +257 // Validierungs-/Interner-Fehler (HTTP-Äquivalent 400/503): als +258 // JSON-RPC-Fehler melden — das war kein Aufnahme-Ergebnis. +259 code := rpcInvalidParams +260 if adm.status != http.StatusBadRequest { +261 code = rpcInternalError +262 } +263 writeRPCError(w, req.ID, code, adm.errMsg) +264 return +265 } +266 writeRPCResult(w, req.ID, speakToolResult(adm.resp)) +267 } +268 +269 // speakTool ist die Tool-Beschreibung von speak: Input-Schema aus dem +270 // Task-06-Brief (text Pflicht; priority/provider/lang/now optional). +271 func speakTool() mcpTool { +272 return mcpTool{ +273 Name: "speak", +274 Description: "Spricht eine kurze Ansage (Status, nächster Schritt, " + +275 "Freigabe-Bedarf — nie Langantworten). Läuft über den Daemon: " + +276 "Queue, Rate-Limit, Mute und Ruhezeiten gelten.", +277 InputSchema: mcpSchema{ +278 Type: "object", +279 Properties: map[string]mcpProperty{ +280 "text": { +281 Type: "string", +282 Description: "Der Ansage-Text (1–2 Sätze Etikette)", +283 }, +284 "priority": { +285 Type: "string", +286 Enum: []string{"low", "normal", "high"}, +287 Description: "Abarbeitungs-Priorität (Default normal)", +288 }, +289 "provider": { +290 Type: "string", +291 Description: "Provider pin (elevenlabs, openai, edge, osbuiltin); leer = Default-Kette", +292 }, +293 "lang": { +294 Type: "string", +295 Enum: []string{"de", "en"}, +296 Description: "Sprache pin; leer = automatische Erkennung", +297 }, +298 "now": { +299 Type: "boolean", +300 Description: "Springt vor die Warteschlange und bricht bewusst aus Rate-Limit und Ruhezeit aus", +301 }, +302 }, +303 Required: []string{"text"}, +304 }, +305 } +306 } +307 +308 // speakToolResult übersetzt eine /speak-Aufnahme-Antwort in das MCP-Tool- +309 // Ergebnis. Mute- und Gesetz-Verwürfe sind bewusst KEIN isError: Sie sind +310 // das korrekte dokumentierte Ergebnis der Aufnahme (kein Protokollfehler) +311 // — der Agent soll den Grund lesen, nicht crashen. Erfolgreiche Aufnahme: +312 // "queued: ". +313 func speakToolResult(resp SpeakResponse) mcpToolResult { +314 switch { +315 case resp.Muted: +316 return mcpToolResult{Content: []mcpContent{{ +317 Type: "text", +318 Text: "muted: Ansage verworfen (Mute ist aktiv)", +319 }}} +320 case resp.Discarded == queue.DiscardRateLimit: +321 return mcpToolResult{Content: []mcpContent{{ +322 Type: "text", +323 Text: "discarded: rate_limit — Ansage verworfen (Priorität low über dem Fenster-Budget)", +324 }}} +325 case resp.Discarded == queue.DiscardQuietHours: +326 text := "discarded: quiet_hours — Ruhezeit aktiv, Ansage verworfen" +327 if resp.Toast { +328 text += " (durch Toast ersetzt, kein Audio)" +329 } +330 return mcpToolResult{Content: []mcpContent{{Type: "text", Text: text}}} +331 case resp.Discarded != "": +332 return mcpToolResult{Content: []mcpContent{{ +333 Type: "text", +334 Text: "discarded: " + resp.Discarded, +335 }}} +336 default: +337 return mcpToolResult{Content: []mcpContent{{ +338 Type: "text", +339 Text: "queued: " + resp.ID, +340 }}} +341 } +342 } +343 +344 // isNotification meldet, ob die ID eine Notification anzeigt (Feld fehlt +345 // oder explizit null — beides nach JSON-RPC 2.0 ohne Antwort). +346 func isNotification(id json.RawMessage) bool { +347 return len(id) == 0 || strings.TrimSpace(string(id)) == "null" +348 } +349 +350 // writeRPCResult schreibt eine erfolgreiche JSON-RPC-Antwort (200, +351 // application/json). +352 func writeRPCResult(w http.ResponseWriter, id json.RawMessage, result any) { +353 writeRPC(w, jsonRPCResponse{JSONRPC: "2.0", ID: id, Result: result}) +354 } +355 +356 // writeRPCError schreibt eine JSON-RPC-Fehler-Antwort; id null, wenn die +357 // Anfrage-ID unbekannt ist (Parse-Fehler). +358 func writeRPCError(w http.ResponseWriter, id json.RawMessage, code int, msg string) { +359 writeRPC(w, jsonRPCResponse{ +360 JSONRPC: "2.0", +361 ID: id, +362 Error: &rpcError{Code: code, Message: msg}, +363 }) +364 } +365 +366 // writeRPC schreibt die JSON-RPC-Antwort in den Response-Writer. +367 func writeRPC(w http.ResponseWriter, resp jsonRPCResponse) { +368 w.Header().Set("Content-Type", "application/json") +369 w.WriteHeader(http.StatusOK) +370 _ = json.NewEncoder(w).Encode(resp) +371 } +372 + +internal/api/server.go --- 1/7 --- Go + 24 // Mute ist der persistente Mut 24 // Mute ist der persistente Mut + .. e-State (Datei mute im Config-Dir). .. e-State (Datei mute im Config-Dir). + 25 Mute queue.MuteStore 25 Mute queue.MuteStore + 26 26 + 27 // Version ist die Daemon-Versi 27 // Version ist die Daemon-Versi + .. on für GET /status. .. on für GET /status und serverInfo. + 28 Version string 28 Version string + 29 29 + 30 // KnownProviders sind die gült 30 // KnownProviders sind die gült + .. igen Provider-Namen (Validierung de .. igen Provider-Namen (Validierung de + .. s .. s + +internal/api/server.go --- 2/7 --- Go + 35 35 // Pin des laufenden Items bzw. erste verfügbare Ketten-Stufe). Optional. + 36 36 Provider func() string + .. 37 + .. 38 // AllowedOrigins ist die CORS-Erlaubnisliste für POST /speak (leer = + .. 39 // keine CORS-Header, der Browser blockt; siehe cors.go). + .. 40 AllowedOrigins []string + 37 41 } + 38 42 + 39 43 // NewHandler liefert die HTTP-Routen des Daemons. Methoden-Bindung per + +internal/api/server.go --- 3/7 --- Go + 45 49 version: cfg.Version, + 46 50 known: make(map[string]bool, len(cfg.KnownProviders)), + 47 51 provider: cfg.Provider, + .. 52 allowedOrigins: cfg.AllowedOrigins, + 48 53 } + 49 54 for _, name := range cfg.KnownProviders { + 50 55 h.known[name] = true + 51 56 } + 52 57 mux := http.NewServeMux() + 53 58 mux.HandleFunc("POST /speak", h.speak) + .. 59 mux.HandleFunc("OPTIONS /speak", h.speakPreflight) // CORS-Preflight (Task 06) + 54 60 mux.HandleFunc("GET /status", h.status) + 55 61 mux.HandleFunc("POST /cancel", h.cancel) + 56 62 mux.HandleFunc("POST /mute", h.muteEndpoint(true)) + 57 63 mux.HandleFunc("POST /unmute", h.muteEndpoint(false)) + .. 64 mux.HandleFunc("POST /mcp", h.serveMCP) // MCP Streamable HTTP (Task 06) + .. 65 mux.HandleFunc("GET /mcp", h.mcpGet) // 405: keine server-initiierten Streams + 58 66 return mux + 59 67 } + 60 68 + +internal/api/server.go --- 4/7 --- Go + 65 version string 73 version string + 66 known map[string]bool 74 known map[string]bool + 67 provider func() string 75 provider func() string + .. 76 allowedOrigins []string + 68 } 77 } + 69 78 + 70 // speak behandelt POST /speak: val 79 // speak behandelt POST /speak: val + .. idieren, bei Mute verwerfen (202, .. idieren, bei Mute verwerfen (202, + 71 // dokumentiertes Verhalten — bewus 80 // dokumentiertes Verhalten — bewus + .. st kein Fehler, damit Aufrufer kein .. st kein Fehler, damit Aufrufer kein + .. e .. e + 72 // Sonderbehandlung brauchen), sons 81 // Sonderbehandlung brauchen), sons + .. t enqueue → 202 {id}. Das Gesetz .. t enqueue → 202 {id}. Das Gesetz + 73 // (Rate-Limit-Budget, Ruhezeit) ka 82 // (Rate-Limit-Budget, Ruhezeit) ka + .. nn beim Eintritt ebenfalls verwerfe .. nn beim Eintritt ebenfalls verwerfe + .. n — .. n — + 74 // die Antwort nennt dann den Grund 83 // die Antwort nennt dann den Grund + .. ({"discarded":"…"}). .. ({"discarded":"…"}). CORS-Header n + .. .. ur + .. 84 // hier und nur bei gelisteter Orig + .. .. in (siehe cors.go). + 75 func (h *handler) speak(w http.Resp 85 func (h *handler) speak(w http.Resp + .. onseWriter, r *http.Request) { .. onseWriter, r *http.Request) { + 76 var req SpeakRequest 86 h.setSpeakCors(w, r) + .. 87 + .. 88 var req SpeakRequest + .. 89 if err := json.NewDecoder(http. + .. .. MaxBytesReader(w, r.Body, maxBodyBy + .. .. tes)).Decode(&req); err != nil { + .. 90 writeError(w, http.StatusBa + .. .. dRequest, "ungültiger JSON-Body: "+ + .. .. err.Error()) + .. 91 return + .. 92 } + .. 93 adm := h.admitSpeak(req) + .. 94 if adm.errMsg != "" { + .. 95 writeError(w, adm.status, a + .. .. dm.errMsg) + .. 96 return + .. 97 } + .. 98 writeJSON(w, adm.status, adm.re + .. .. sp) + .. 99 } + .. 100 + .. 101 // speakAdmission ist das Ergebnis + .. ... des gemeinsamen Aufnahme-Pfads von + .. 102 // POST /speak und dem MCP-Tool spe + .. ... ak: HTTP-Status-Äquivalent plus Bod + .. ... y — + .. 103 // 202 mit {id}/{muted}/{discarded} + .. ... als Aufnahme-Ergebnis, 400/503 mit + .. 104 // errMsg als Fehler (MCP übersetzt + .. ... beides, siehe mcp.go). + .. 105 type speakAdmission struct { + .. 106 status int + .. 107 resp SpeakResponse + .. 108 errMsg string + .. 109 } + .. 110 + .. 111 // admitSpeak ist der eine Aufnahme + .. ... -Pfad hinter POST /speak und dem + .. 112 // MCP-Tool speak (Task 06): Validi + .. ... erung, Mute-Check, Enqueue inklusiv + .. ... e + .. 113 // Gesetz (Rate-Limit-Budget, Ruhez + .. ... eit) — identische Semantik für beid + .. ... e + .. 114 // Türen, kein Contract-Drift. + 77 if err := json.NewDecoder(http. 115 func (h *handler) admitSpeak(req Sp + .. MaxBytesReader(w, r.Body, maxBodyBy ... eakRequest) speakAdmission { + .. tes)).Decode(&req); err != nil { ... + 78 writeError(w, http.StatusBa ... + .. dRequest, "ungültiger JSON-Body: "+ ... + .. err.Error()) ... + 79 return ... + 80 } ... + 81 if strings.TrimSpace(req.Text) 116 if strings.TrimSpace(req.Text) + .. == "" { ... == "" { + 82 writeError(w, http.StatusBa 117 return speakAdmission{statu + .. dRequest, "text fehlt oder ist leer ... s: http.StatusBadRequest, errMsg: " + .. ") ... text fehlt oder ist leer"} + 83 return ... + 84 } 118 } + 85 if req.Priority == "" { 119 if req.Priority == "" { + 86 req.Priority = string(queue 120 req.Priority = string(queue + .. .PriorityNormal) ... .PriorityNormal) + 87 } 121 } + 88 if !queue.Priority(req.Priority 122 if !queue.Priority(req.Priority + .. ).Valid() { ... ).Valid() { + 89 writeError(w, http.StatusBa 123 return speakAdmission{statu + .. dRequest, fmt.Sprintf("priority: un ... s: http.StatusBadRequest, + .. gültiger Wert %q (erlaubt: low, nor ... + .. mal, high)", req.Priority)) ... + 90 return 124 errMsg: fmt.Sprintf("pr + .. ... iority: ungültiger Wert %q (erlaubt + .. ... : low, normal, high)", req.Priority + .. ... )} + 91 } 125 } + 92 if req.Provider != "" && !h.kno 126 if req.Provider != "" && !h.kno + .. wn[req.Provider] { ... wn[req.Provider] { + 93 writeError(w, http.StatusBa 127 return speakAdmission{statu + .. dRequest, fmt.Sprintf("provider: un ... s: http.StatusBadRequest, + .. bekannter Wert %q", req.Provider)) ... + 94 return 128 errMsg: fmt.Sprintf("pr + .. ... ovider: unbekannter Wert %q", req.P + .. ... rovider)} + 95 } 129 } + 96 if req.Lang != "" && req.Lang ! 130 if req.Lang != "" && req.Lang ! + .. = "de" && req.Lang != "en" { ... = "de" && req.Lang != "en" { + 97 writeError(w, http.StatusBa 131 return speakAdmission{statu + .. dRequest, fmt.Sprintf("lang: ungült ... s: http.StatusBadRequest, + .. iger Wert %q (erlaubt: de, en)", re ... + .. q.Lang)) ... + 98 return 132 errMsg: fmt.Sprintf("la + .. ... ng: ungültiger Wert %q (erlaubt: de + .. ... , en)", req.Lang)} + 99 } 133 } +100 134 +101 if h.mute.Muted() { 135 if h.mute.Muted() { +102 writeJSON(w, http.StatusAcc 136 return speakAdmission{statu +... epted, SpeakResponse{Muted: true}) ... s: http.StatusAccepted, resp: Speak +... ... Response{Muted: true}} +103 return ... +104 } 137 } +105 138 +106 adm, err := h.queue.Enqueue(que 139 adm, err := h.queue.Enqueue(que +... ue.Item{ ... ue.Item{ + +internal/api/server.go --- 5/7 --- Go +112 Now: req.Now, 145 Now: req.Now, +113 }) 146 }) +114 if err != nil { 147 if err != nil { +115 writeError(w, http.StatusSe 148 return speakAdmission{statu +... rviceUnavailable, "enqueue: "+err.E ... s: http.StatusServiceUnavailable, e +... rror()) ... rrMsg: "enqueue: " + err.Error()} +116 return ... +117 } 149 } +118 if adm.Discarded != "" { 150 if adm.Discarded != "" { +119 // Gesetz-Verwurf (Rate-Lim 151 // Gesetz-Verwurf (Rate-Lim +... it-Budget / Ruhezeit): dokumentiert ... it-Budget / Ruhezeit): dokumentiert +... es ... es +120 // Verhalten wie bei Mute — 152 // Verhalten wie bei Mute — +... 202 mit Grund statt Fehler, damit ... 202 mit Grund statt Fehler, damit +121 // Aufrufer keine Sonderbeh 153 // Aufrufer keine Sonderbeh +... andlung brauchen. ... andlung brauchen. +122 writeJSON(w, http.StatusAcc 154 return speakAdmission{statu +... epted, SpeakResponse{Discarded: adm ... s: http.StatusAccepted, +... .Discarded, Toast: adm.Toast}) ... +123 return 155 resp: SpeakResponse{Dis +... ... carded: adm.Discarded, Toast: adm.T +... ... oast}} +124 } 156 } +125 writeJSON(w, http.StatusAccepte 157 return speakAdmission{status: h +... d, SpeakResponse{ID: adm.ID}) ... ttp.StatusAccepted, resp: SpeakResp +... ... onse{ID: adm.ID}} +126 } 158 } +127 159 +128 // status behandelt GET /status: Qu 160 // status behandelt GET /status: Qu +... eue-Länge (+ wartende IDs), aktuell ... eue-Länge (+ wartende IDs), aktuell +... es ... es +129 // Item, Mute-State, aktiver Provid 161 // Item, Mute-State, aktiver Provid +... er, Version — und seit Task 05 den ... er, Version — und seit Task 05 den + +internal/api/server.go --- 6/7 --- Go +139 171 Queue: len(pending), +140 172 QueueIDs: ids, +141 173 Muted: h.mute.Muted(), +... 174 MCP: true, +... 175 Cors: corsList(h.allowedOrigins), +142 176 } +143 177 if cur, ok := h.queue.Current(); ok { +144 178 st.Current = &CurrentItem{ + +internal/api/server.go --- 7/7 --- Go +155 189 writeJSON(w, http.StatusOK, st) +156 190 } +... 191 +... 192 // corsList kopiert die Erlaubnisliste als JSON-[] statt null: Der Status +... 193 // soll "cors": [] zeigen, wenn die Tür zu ist. +... 194 func corsList(origins []string) []string { +... 195 out := make([]string, len(origins)) +... 196 copy(out, origins) +... 197 return out +... 198 } +157 199 +158 200 // lawState überführt die Gesetz-Sicht der Queue in die JSON-Form von +159 201 // /status: Zeitstempel nur, wenn sie in der Zukunft liegen (null = Slot + +internal/api/types.go --- 1/2 --- Go +55 // Item (null im Leerlauf), Mute-Sta 55 // Item (null im Leerlauf), Mute-Sta +.. te, aktiver Provider, Daemon-Version .. te, aktiver Provider, Daemon-Version +.. . .. . +56 // QueueIDs ist die Ergänzung der wa 56 // QueueIDs ist die Ergänzung der wa +.. rtenden IDs in Abarbeitungs-Reihenfo .. rtenden IDs in Abarbeitungs-Reihenfo +.. lge .. lge +57 // — die CLI braucht sie für ihr --w 57 // — die CLI braucht sie für ihr --w +.. ait-Polling. Task 05 ergänzt additiv .. ait-Polling. Task 05 ergänzt additiv +58 // den Gesetz-State (rate_limit, qui 58 // den Gesetz-State (rate_limit, qui +.. et_hours). .. et_hours); Task 06 additiv mcp/cors +.. 59 // (Compat-Felder: MCP-Endpoint vorh +.. .. anden, konfigurierte CORS-Origins). +59 type StatusResponse struct { 60 type StatusResponse struct { +60 Version string `jso 61 Version string `jso +.. n:"version"` .. n:"version"` +61 Queue int `jso 62 Queue int `jso +.. n:"queue"` .. n:"queue"` + +internal/api/types.go --- 2/2 --- Go +66 67 RateLimit *RateLimitState `json:"rate_limit,omitempty"` +67 68 QuietHours *QuietHoursState `json:"quiet_hours,omitempty"` +.. 69 +.. 70 // MCP meldet, dass der Daemon den MCP-Endpoint anbietet (immer true; +.. 71 // Feld existiert für additive Compat-Sicht von Clients/Browsern). +.. 72 MCP bool `json:"mcp"` +.. 73 +.. 74 // Cors ist die konfigurierte CORS-Erlaubnisliste von POST /speak +.. 75 // (leeres [] = Tür zu). Immer vorhanden (auch leer), nicht omitempty. +.. 76 Cors []string `json:"cors"` +68 77 } +69 78 +70 79 // RateLimitState ist der Rate-Limit-State in GET /status (Task 05). + +internal/config/config.go --- 1/5 --- Go + 6 6 "os" + 7 7 "path/filepath" + 8 8 "strconv" + . 9 "strings" + 9 10 "time" + 10 11 + 11 12 "github.com/BurntSushi/toml" + +internal/config/config.go --- 2/5 --- Go + 86 87 // `[server] port = 7800`, Env-Override TALK_PORT, Default 7800. + 87 88 Port int `toml:"port"` + .. 89 + .. 90 // AllowedOrigins ist die CORS-Erlaubnisliste für POST /speak (Task 06): + .. 91 // exakte Origins wie "https://chat.az-gruppe.com". Der Eintrag "*" + .. 92 // erlaubt jede Origin ausdrücklich. Bewusster Default: LEER — ohne + .. 93 // Einträge sendet der Daemon gar keine CORS-Header und der Browser + .. 94 // blockt Cross-Origin-Anfragen; die Tür ist zu, bis sie konfiguriert + .. 95 // wird (Env-Override TALK_ALLOWED_ORIGINS, kommagetrennt; "off" leert). + .. 96 AllowedOrigins []string `toml:"allowed_origins"` + 88 97 } + 89 98 + 90 99 // Queue sind die Rate-Limit-Gesetze des Daemons (TOML `[queue]`). + +internal/config/config.go --- 3/5 --- Go +178 187 } +179 188 cfg.Server.Port = port +180 189 } +... 190 if v := getenv("TALK_ALLOWED_ORIGINS"); v != "" { +... 191 if v == "off" { +... 192 cfg.Server.AllowedOrigins = nil +... 193 } else { +... 194 origins, err := splitOrigins(v) +... 195 if err != nil { +... 196 return cfg, fmt.Errorf("TALK_ALLOWED_ORIGINS: %w", err) +... 197 } +... 198 cfg.Server.AllowedOrigins = origins +... 199 } +... 200 } +181 201 if v := getenv("TALK_RATE_LIMIT"); v != "" { +182 202 d, err := time.ParseDuration(v) +183 203 if err != nil || d < 0 { + +internal/config/config.go --- 4/5 --- Go +215 235 return cfg, fmt.Errorf("audio.sink: ungültiger Wert %q (erlaubt: %q, %q)", +216 236 cfg.Audio.Sink, SinkOto, SinkNull) +217 237 } +... 238 // allowed_origins aus der Datei prüfen (Env wurde oben geparst): Eine +... 239 // leere Liste ist gültig (Tür zu), einzelne Einträge müssen aber +... 240 // substanziell sein — klarer Fehler statt stiller Ignoranz. +... 241 for _, o := range cfg.Server.AllowedOrigins { +... 242 if strings.TrimSpace(o) == "" { +... 243 return cfg, fmt.Errorf("server.allowed_origins: leerer Eintrag in %v", cfg.Server.AllowedOrigins) +... 244 } +... 245 } +218 246 // quiet_hours aus der Datei validieren (Env wurde oben geprüft) und +219 247 // quiet_mode leer (Datei ohne Wert) auf den Default heben. +220 248 if cfg.QuietHours != "" { + +internal/config/config.go --- 5/5 --- Go +228 256 return cfg, nil +229 257 } + 258 + 259 // splitOrigins zerlegt eine kommagetrennte Origin-Liste (Env-Form von + 260 // server.allowed_origins, z. B. "https://a.example, https://b.example"). + 261 // Whitespace um Einträge toleriert, leere Einträge sind ein klarer Fehler. + 262 func splitOrigins(v string) ([]string, error) { + 263 parts := strings.Split(v, ",") + 264 origins := make([]string, 0, len(parts)) + 265 for _, p := range parts { + 266 p = strings.TrimSpace(p) + 267 if p == "" { + 268 return nil, fmt.Errorf("leerer Eintrag in %q (komma-getrennte Origins oder \"off\")", v) + 269 } + 270 origins = append(origins, p) + 271 } + 272 return origins, nil + 273 } + +internal/config/config_test.go --- Go +356 356 }) +357 357 } +... 358 +... 359 func TestLoadAllowedOrigins(t *testing.T) { +... 360 writeTOML := func(t *testing.T, content string) string { +... 361 t.Helper() +... 362 path := filepath.Join(t.TempDir(), "config.toml") +... 363 if err := os.WriteFile(path, []byte(content), 0o644); err != nil { +... 364 t.Fatal(err) +... 365 } +... 366 return path +... 367 } +... 368 +... 369 t.Run("Default: leere Liste (kein CORS)", func(t *testing.T) { +... 370 cfg, err := Load(Options{ +... 371 File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"), +... 372 Getenv: env(nil), +... 373 }) +... 374 if err != nil { +... 375 t.Fatalf("Load: %v", err) +... 376 } +... 377 if len(cfg.Server.AllowedOrigins) != 0 { +... 378 t.Errorf("AllowedOrigins = %v, want leer (Tür zu)", cfg.Server.AllowedOrigins) +... 379 } +... 380 }) +... 381 +... 382 t.Run("TOML-Liste", func(t *testing.T) { +... 383 cfg, err := Load(Options{ +... 384 File: writeTOML(t, "[server]\nallowed_origins = [\"https://chat.az-gruppe.com\"]\n"), +... 385 Getenv: env(nil), +... 386 }) +... 387 if err != nil { +... 388 t.Fatalf("Load: %v", err) +... 389 } +... 390 want := []string{"https://chat.az-gruppe.com"} +... 391 if strings.Join(cfg.Server.AllowedOrigins, "|") != strings.Join(want, "|") { +... 392 t.Errorf("AllowedOrigins = %v, want %v", cfg.Server.AllowedOrigins, want) +... 393 } +... 394 }) +... 395 +... 396 t.Run("TALK_ALLOWED_ORIGINS schlägt Datei (mehrere, mit Leerzeichen)", func(t *testing.T) { +... 397 cfg, err := Load(Options{ +... 398 File: writeTOML(t, "[server]\nallowed_origins = [\"https://datei.example\"]\n"), +... 399 Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "https://chat.az-gruppe.com, https://grid.ag"}), +... 400 }) +... 401 if err != nil { +... 402 t.Fatalf("Load: %v", err) +... 403 } +... 404 want := []string{"https://chat.az-gruppe.com", "https://grid.ag"} +... 405 if strings.Join(cfg.Server.AllowedOrigins, "|") != strings.Join(want, "|") { +... 406 t.Errorf("AllowedOrigins = %v, want %v", cfg.Server.AllowedOrigins, want) +... 407 } +... 408 }) +... 409 +... 410 t.Run("TALK_ALLOWED_ORIGINS=off leert die Datei-Liste", func(t *testing.T) { +... 411 cfg, err := Load(Options{ +... 412 File: writeTOML(t, "[server]\nallowed_origins = [\"https://datei.example\"]\n"), +... 413 Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "off"}), +... 414 }) +... 415 if err != nil { +... 416 t.Fatalf("Load: %v", err) +... 417 } +... 418 if len(cfg.Server.AllowedOrigins) != 0 { +... 419 t.Errorf("AllowedOrigins = %v, want leer (off)", cfg.Server.AllowedOrigins) +... 420 } +... 421 }) +... 422 +... 423 t.Run("Wildcard ist erlaubter Wert", func(t *testing.T) { +... 424 cfg, err := Load(Options{ +... 425 File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"), +... 426 Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "*"}), +... 427 }) +... 428 if err != nil { +... 429 t.Fatalf("Load: %v", err) +... 430 } +... 431 if strings.Join(cfg.Server.AllowedOrigins, "|") != "*" { +... 432 t.Errorf("AllowedOrigins = %v, want [*]", cfg.Server.AllowedOrigins) +... 433 } +... 434 }) +... 435 +... 436 t.Run("leere Einträge sind klare Fehler", func(t *testing.T) { +... 437 for _, tc := range []struct { +... 438 name string +... 439 file string +... 440 envv string +... 441 }{ +... 442 {"env", "", "https://a.example,,https://b.example"}, +... 443 {"datei", "[server]\nallowed_origins = [\"https://a.example\", \"\"]\n", ""}, +... 444 } { +... 445 t.Run(tc.name, func(t *testing.T) { +... 446 file := filepath.Join(t.TempDir(), "gibt-es-nicht.toml") +... 447 if tc.file != "" { +... 448 file = writeTOML(t, tc.file) +... 449 } +... 450 _, err := Load(Options{ +... 451 File: file, +... 452 Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": tc.envv}), +... 453 }) +... 454 if err == nil || !strings.Contains(err.Error(), "allowed_origins") && !strings.Contains(err.Error(), "ALLOWED_ORIGINS") { +... 455 t.Fatalf("Fehler = %v, will klaren allowed_origins-Fehler", err) +... 456 } +... 457 }) +... 458 } +... 459 }) +... 460 } +358 461 +359 462 // TestLoadGesetze deckt die Task-05-Felder ab: Rate-Limit (Default 30 s, +360 463 // "0s" = unbegrenzt), High-Burst (Default an), Ruhezeiten (Default aus; + +test/cors_test.go --- Go + 1 package test + 2 + 3 // Prozessgrenzen-Tests für die CORS-Tür (Task 06): OPTIONS-Preflight und + 4 // echter POST auf /speak mit Headern aus der Config-Liste + 5 // TALK_ALLOWED_ORIGINS — beobachtet als Black Box über rohe HTTP-Requests. + 6 // Bindend: exakte Origins; "*" nur explizit; leere Liste = gar keine + 7 // CORS-Header (der Browser blockt); CORS nur auf /speak. + 8 + 9 import ( + 10 "net/http" + 11 "testing" + 12 ) + 13 + 14 const azOrigin = "https://chat.az-gruppe.com" + 15 + 16 // TestCORSPraeflightGelisted (Abnahmekriterium): Preflight von gelisteter + 17 // Origin → 204 mit Freigabe-Headern; ungeliste Origin → 204 OHNE + 18 // Access-Control-Allow-Origin. + 19 func TestCORSPraeflightGelisted(t *testing.T) { + 20 root := t.TempDir() + 21 fake := newFakeProvider(t, 0) + 22 d := startDaemon(t, root, fake.srv.URL, map[string]string{ + 23 "TALK_ALLOWED_ORIGINS": azOrigin, + 24 }) + 25 + 26 raw := preflight(t, d, azOrigin) + 27 if raw.status != http.StatusNoContent { + 28 t.Fatalf("Preflight-Status = %d, want 204", raw.status) + 29 } + 30 if got := raw.header.Get("Access-Control-Allow-Origin"); got != azOrigin { + 31 t.Errorf("ACAO = %q, want %q", got, azOrigin) + 32 } + 33 if methods := raw.header.Get("Access-Control-Allow-Methods"); methods != "POST, OPTIONS" { + 34 t.Errorf("Access-Control-Allow-Methods = %q, want %q", methods, "POST, OPTIONS") + 35 } + 36 if headers := raw.header.Get("Access-Control-Allow-Headers"); headers != "Content-Type" { + 37 t.Errorf("Access-Control-Allow-Headers = %q, want %q", headers, "Content-Type") + 38 } + 39 if vary := raw.header.Get("Vary"); vary != "Origin" { + 40 t.Errorf("Vary = %q, want Origin (Cache-Korrektheit)", vary) + 41 } + 42 + 43 // Ungelistete Origin: sauberer 204, aber bewusst ohne CORS-Header — + 44 // der Browser verwirft den geplanten POST, der Daemon bleibt erreichbar. + 45 raw = preflight(t, d, "https://evil.example") + 46 if raw.status != http.StatusNoContent { + 47 t.Fatalf("Preflight (ungelisted) Status = %d, want 204", raw.status) + 48 } + 49 if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" { + 50 t.Errorf("ACAO für ungeliste Origin = %q, want leer", got) + 51 } + 52 } + 53 + 54 // TestCORSPostGelisted: Auch der echte (non-preflight) POST trägt ACAO, + 55 // wenn die Anfrage-Origin gelistet ist — sonst nicht. + 56 func TestCORSPostGelisted(t *testing.T) { + 57 root := t.TempDir() + 58 fake := newFakeProvider(t, 0) + 59 d := startDaemon(t, root, fake.srv.URL, map[string]string{ + 60 "TALK_ALLOWED_ORIGINS": azOrigin, + 61 }) + 62 + 63 raw := doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{ + 64 "Origin": azOrigin, + 65 "Content-Type": "application/json", + 66 }, `{"text":"browser sagt hallo"}`) + 67 if raw.status != http.StatusAccepted { + 68 t.Fatalf("POST /speak Status = %d, want 202", raw.status) + 69 } + 70 if got := raw.header.Get("Access-Control-Allow-Origin"); got != azOrigin { + 71 t.Errorf("ACAO auf POST = %q, want %q", got, azOrigin) + 72 } + 73 fake.waitCompleted(t, 1) + 74 + 75 raw = doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{ + 76 "Origin": "https://evil.example", + 77 "Content-Type": "application/json", + 78 }, `{"text":"böser browser"}`) + 79 if raw.status != http.StatusAccepted { + 80 t.Fatalf("POST /speak (ungelisted) Status = %d, want 202 (HTTP bleibt sauber)", raw.status) + 81 } + 82 if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" { + 83 t.Errorf("ACAO für ungeliste Origin = %q, want leer", got) + 84 } + 85 } + 86 + 87 // TestCORSLeereListe: Ohne allowed_origins (Default) sendet der Daemon + 88 // GAR KEINE CORS-Header — Tür zu, der Browser blockt. + 89 func TestCORSLeereListe(t *testing.T) { + 90 root := t.TempDir() + 91 fake := newFakeProvider(t, 0) + 92 d := startDaemon(t, root, fake.srv.URL, nil) // keine Origins konfiguriert + 93 + 94 raw := preflight(t, d, azOrigin) + 95 if raw.status != http.StatusNoContent { + 96 t.Fatalf("Preflight-Status = %d, want 204", raw.status) + 97 } + 98 for _, h := range []string{ + 99 "Access-Control-Allow-Origin", +100 "Access-Control-Allow-Methods", +101 "Access-Control-Allow-Headers", +102 } { +103 if got := raw.header.Get(h); got != "" { +104 t.Errorf("%s = %q bei leerer Liste, want nicht gesetzt", h, got) +105 } +106 } +107 +108 raw = doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{ +109 "Origin": azOrigin, +110 "Content-Type": "application/json", +111 }, `{"text":"ohne cors"}`) +112 if raw.status != http.StatusAccepted { +113 t.Fatalf("POST /speak Status = %d, want 202", raw.status) +114 } +115 if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" { +116 t.Errorf("ACAO auf POST bei leerer Liste = %q, want leer", got) +117 } +118 } +119 +120 // TestCORSWildcard: Der Eintrag "*" erlaubt jede Origin ausdrücklich — +121 // ACAO ist dann das literale "*". +122 func TestCORSWildcard(t *testing.T) { +123 root := t.TempDir() +124 fake := newFakeProvider(t, 0) +125 d := startDaemon(t, root, fake.srv.URL, map[string]string{ +126 "TALK_ALLOWED_ORIGINS": "*", +127 }) +128 +129 for _, origin := range []string{"https://irgendwo.example", "http://localhost:5173"} { +130 raw := preflight(t, d, origin) +131 if raw.status != http.StatusNoContent { +132 t.Fatalf("Preflight von %s: Status = %d, want 204", origin, raw.status) +133 } +134 if got := raw.header.Get("Access-Control-Allow-Origin"); got != "*" { +135 t.Errorf("ACAO von %s = %q, want *", origin, got) +136 } +137 } +138 } +139 +140 // TestCORSNurAufSpeak: Kein anderer Endpunkt trägt CORS-Header — OPTIONS +141 // auf /status ist 405 (kein Preflight-Support), GET /status ohne ACAO. +142 func TestCORSNurAufSpeak(t *testing.T) { +143 root := t.TempDir() +144 fake := newFakeProvider(t, 0) +145 d := startDaemon(t, root, fake.srv.URL, map[string]string{ +146 "TALK_ALLOWED_ORIGINS": azOrigin, +147 }) +148 +149 raw := doRequest(t, http.MethodOptions, d.baseURL+"/status", map[string]string{ +150 "Origin": azOrigin, +151 "Access-Control-Request-Method": http.MethodGet, +152 }, "") +153 if raw.status != http.StatusMethodNotAllowed { +154 t.Fatalf("OPTIONS /status Status = %d, want 405 (kein CORS-Support)", raw.status) +155 } +156 if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" { +157 t.Errorf("OPTIONS /status ACAO = %q, want leer", got) +158 } +159 +160 raw = doRequest(t, http.MethodGet, d.baseURL+"/status", map[string]string{ +161 "Origin": azOrigin, +162 }, "") +163 if raw.status != http.StatusOK { +164 t.Fatalf("GET /status Status = %d, want 200", raw.status) +165 } +166 if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" { +167 t.Errorf("GET /status ACAO = %q, want leer (CORS nur auf /speak)", got) +168 } +169 } +170 +171 // TestStatusCorsFeld: GET /status trägt die CORS-Origins als cors-Feld — +172 // gelistet als [], wenn die Tür zu ist (Compat-Anforderung Task 06). +173 func TestStatusCorsFeld(t *testing.T) { +174 t.Run("gelistet", func(t *testing.T) { +175 root := t.TempDir() +176 fake := newFakeProvider(t, 0) +177 d := startDaemon(t, root, fake.srv.URL, map[string]string{ +178 "TALK_ALLOWED_ORIGINS": azOrigin + ", https://grid.ag", +179 }) +180 st := getStatus(t, d) +181 if len(st.Cors) != 2 || st.Cors[0] != azOrigin || st.Cors[1] != "https://grid.ag" { +182 t.Errorf("cors = %v, want beide gelisteten Origins", st.Cors) +183 } +184 }) +185 +186 t.Run("leer = [] (nicht null)", func(t *testing.T) { +187 root := t.TempDir() +188 fake := newFakeProvider(t, 0) +189 d := startDaemon(t, root, fake.srv.URL, nil) +190 st := getStatus(t, d) +191 if st.Cors == nil || len(st.Cors) != 0 { +192 t.Errorf("cors = %v, want [] bei geschlossener Tür", st.Cors) +193 } +194 }) +195 } +196 + +test/harness_test.go --- 1/3 --- Go + 13 13 "bytes" + 14 14 "encoding/json" + 15 15 "fmt" + .. 16 "io" + 16 17 "net" + 17 18 "net/http" + 18 19 "net/http/httptest" + +test/harness_test.go --- 2/3 --- Go +403 404 } `json:"current"` +404 405 Muted bool `json:"muted"` +405 406 Provider string `json:"provider"` +... 407 MCP bool `json:"mcp"` +... 408 Cors []string `json:"cors"` +406 409 } +407 410 +408 411 // getStatus ruft GET /status auf (Deadline: eine Sekunde — der Daemon ist + +test/harness_test.go --- 3/3 --- Go +472 475 return resp.StatusCode, m +473 476 } + 477 + 478 // rawResponse ist eine vollständig gelesene HTTP-Antwort (Status, Header, + 479 // Body als String) — Beobachtungsform der rohen CORS-/MCP-Tests. + 480 type rawResponse struct { + 481 status int + 482 header http.Header + 483 body string + 484 } + 485 + 486 // doRequest macht einen rohen HTTP-Request mit beliebigen Headern und + 487 // optionalem Body und liest die Antwort komplett (CORS-Preflights und + 488 // MCP-JSON-RPC-Payloads sind literal, kein Client-SDK dazwischen). + 489 func doRequest(t *testing.T, method, url string, headers map[string]string, body string) rawResponse { + 490 t.Helper() + 491 var rd io.Reader + 492 if body != "" { + 493 rd = strings.NewReader(body) + 494 } + 495 req, err := http.NewRequest(method, url, rd) + 496 if err != nil { + 497 t.Fatalf("%s %s bauen: %v", method, url, err) + 498 } + 499 for k, v := range headers { + 500 req.Header.Set(k, v) + 501 } + 502 resp, err := http.DefaultClient.Do(req) + 503 if err != nil { + 504 t.Fatalf("%s %s: %v", method, url, err) + 505 } + 506 defer resp.Body.Close() + 507 raw, err := io.ReadAll(resp.Body) + 508 if err != nil { + 509 t.Fatalf("%s %s Antwort lesen: %v", method, url, err) + 510 } + 511 return rawResponse{status: resp.StatusCode, header: resp.Header, body: string(raw)} + 512 } + 513 + 514 // preflight macht einen OPTIONS-Preflight gegen /speak (so wie ein Browser + 515 // es vor einem echten POST tut). + 516 func preflight(t *testing.T, d *daemonProc, origin string) rawResponse { + 517 t.Helper() + 518 return doRequest(t, http.MethodOptions, d.baseURL+"/speak", map[string]string{ + 519 "Origin": origin, + 520 "Access-Control-Request-Method": http.MethodPost, + 521 }, "") + 522 } + 523 + 524 // mcpPost POSTet einen literalen JSON-RPC-Payload an /mcp. + 525 func mcpPost(t *testing.T, d *daemonProc, payload string) rawResponse { + 526 t.Helper() + 527 return doRequest(t, http.MethodPost, d.baseURL+"/mcp", map[string]string{ + 528 "Content-Type": "application/json", + 529 }, payload) + 530 } + 531 + 532 // mcpDecode dekodiert eine JSON-RPC-Antwort in eine generische Map (für + 533 // Feld-Zugriffe der literalen Payload-Tests). + 534 func mcpDecode(t *testing.T, raw rawResponse) map[string]any { + 535 t.Helper() + 536 var m map[string]any + 537 if err := json.Unmarshal([]byte(raw.body), &m); err != nil { + 538 t.Fatalf("JSON-RPC-Antwort dekodieren: %v — Body: %s", err, raw.body) + 539 } + 540 return m + 541 } + +test/mcp_test.go --- Go + 1 package test + 2 + 3 // Prozessgrenzen-Tests für den MCP-Endpoint (Task 06): der komplette + 4 // Handshake (initialize → notifications/initialized → tools/list → + 5 // tools/call) als rohe JSON-RPC-HTTP-Payloads gegen einen echten talkd — + 6 // literal, ohne Client-SDK. tools/call muss sich exakt wie POST /speak + 7 // verhalten: dieselbe Queue-Aufnahme inklusive Mute und Gesetz. + 8 + 9 import ( + 10 "fmt" + 11 "net/http" + 12 "strings" + 13 "testing" + 14 "time" + 15 ) + 16 + 17 // mcpToolText liest content[0].text aus einem tools/call-Ergebnis. + 18 func mcpToolText(t *testing.T, m map[string]any) string { + 19 t.Helper() + 20 result, ok := m["result"].(map[string]any) + 21 if !ok { + 22 t.Fatalf("tools/call ohne result: %v", m) + 23 } + 24 content, ok := result["content"].([]any) + 25 if !ok || len(content) == 0 { + 26 t.Fatalf("tools/call ohne content: %v", result) + 27 } + 28 first, ok := content[0].(map[string]any) + 29 if !ok { + 30 t.Fatalf("content[0] ist kein Objekt: %v", content[0]) + 31 } + 32 text, _ := first["text"].(string) + 33 return text + 34 } + 35 + 36 // mcpIsError liest isError (fehlt = false — bewusste Semantik). + 37 func mcpIsError(m map[string]any) bool { + 38 if result, ok := m["result"].(map[string]any); ok { + 39 if e, ok := result["isError"].(bool); ok { + 40 return e + 41 } + 42 } + 43 return false + 44 } + 45 + 46 // TestMCPHandshake (Abnahmekriterium): die curl-Sequenz initialize → + 47 // notifications/initialized → tools/list → tools/call sieht das speak-Tool + 48 // und enqueued erfolgreich — die Ansage läuft durch Queue und Fake-Provider. + 49 func TestMCPHandshake(t *testing.T) { + 50 root := t.TempDir() + 51 fake := newFakeProvider(t, 0) + 52 d := startDaemon(t, root, fake.srv.URL, nil) + 53 + 54 // 1. initialize: angefragte Version wird geechot; capabilities nennen + 55 // tools; serverInfo ist talkd. + 56 raw := mcpPost(t, d, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl-test","version":"0"}}}`) + 57 if raw.status != http.StatusOK { + 58 t.Fatalf("initialize: Status %d — Body: %s", raw.status, raw.body) + 59 } + 60 if ct := raw.header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + 61 t.Errorf("initialize Content-Type = %q, want application/json", ct) + 62 } + 63 m := mcpDecode(t, raw) + 64 if m["jsonrpc"] != "2.0" || fmt.Sprint(m["id"]) != "1" { + 65 t.Fatalf("initialize envelope = %v (jsonrpc/id prüfen)", m) + 66 } + 67 result, ok := m["result"].(map[string]any) + 68 if !ok { + 69 t.Fatalf("initialize ohne result: %v", m) + 70 } + 71 if result["protocolVersion"] != "2025-06-18" { + 72 t.Errorf("protocolVersion = %v, want Echo der angefragten 2025-06-18", result["protocolVersion"]) + 73 } + 74 caps, ok := result["capabilities"].(map[string]any) + 75 if !ok { + 76 t.Fatalf("capabilities = %v, want Objekt", result["capabilities"]) + 77 } + 78 if _, ok := caps["tools"]; !ok { + 79 t.Errorf("capabilities ohne tools: %v", caps) + 80 } + 81 serverInfo, _ := result["serverInfo"].(map[string]any) + 82 if serverInfo["name"] != "talkd" { + 83 t.Errorf("serverInfo.name = %v, want talkd", serverInfo["name"]) + 84 } + 85 if v, _ := serverInfo["version"].(string); v == "" { + 86 t.Error("serverInfo.version ist leer") + 87 } + 88 + 89 // Andere angefragte Version wird ebenso geechot (akzeptiert). + 90 raw = mcpPost(t, d, `{"jsonrpc":"2.0","id":2,"method":"initialize","params":{"protocolVersion":"2025-03-26"}}`) + 91 m = mcpDecode(t, raw) + 92 if m["result"].(map[string]any)["protocolVersion"] != "2025-03-26" { + 93 t.Errorf("protocolVersion = %v, want Echo 2025-03-26", m["result"]) + 94 } + 95 + 96 // 2. notifications/initialized: Notification → 202 ohne Body. + 97 raw = mcpPost(t, d, `{"jsonrpc":"2.0","method":"notifications/initialized"}`) + 98 if raw.status != http.StatusAccepted { + 99 t.Fatalf("notifications/initialized: Status %d, want 202", raw.status) +100 } +101 if raw.body != "" { +102 t.Errorf("notifications/initialized Body = %q, want leer", raw.body) +103 } +104 +105 // 3. tools/list: genau das speak-Tool mit dem Brief-Schema. +106 raw = mcpPost(t, d, `{"jsonrpc":"2.0","id":3,"method":"tools/list"}`) +107 m = mcpDecode(t, raw) +108 result = m["result"].(map[string]any) +109 tools, ok := result["tools"].([]any) +110 if !ok || len(tools) != 1 { +111 t.Fatalf("tools = %v, want genau ein Tool", result["tools"]) +112 } +113 tool := tools[0].(map[string]any) +114 if tool["name"] != "speak" { +115 t.Errorf("tool.name = %v, want speak", tool["name"]) +116 } +117 schema, ok := tool["inputSchema"].(map[string]any) +118 if !ok { +119 t.Fatalf("inputSchema = %v, want Objekt", tool["inputSchema"]) +120 } +121 if schema["type"] != "object" { +122 t.Errorf("inputSchema.type = %v, want object", schema["type"]) +123 } +124 props := schema["properties"].(map[string]any) +125 for _, name := range []string{"text", "priority", "provider", "lang", "now"} { +126 if _, ok := props[name]; !ok { +127 t.Errorf("inputSchema ohne property %q: %v", name, props) +128 } +129 } +130 if len(props) != 5 { +131 t.Errorf("inputSchema hat %d properties (%v), want genau die 5 aus dem Brief", len(props), props) +132 } +133 if req, ok := schema["required"].([]any); !ok || len(req) != 1 || req[0] != "text" { +134 t.Errorf("required = %v, want [text]", schema["required"]) +135 } +136 +137 // 4. tools/call: Aufnahme wie POST /speak — "queued: " und die +138 // Ansage taucht am Fake-Provider auf (gleiche Queue, kein Parallelweg). +139 raw = mcpPost(t, d, `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"speak","arguments":{"text":"hallo über mcp"}}}`) +140 if raw.status != http.StatusOK { +141 t.Fatalf("tools/call: Status %d — Body: %s", raw.status, raw.body) +142 } +143 m = mcpDecode(t, raw) +144 if fmt.Sprint(m["id"]) != "4" { +145 t.Errorf("tools/call id = %v, want Echo 4", m["id"]) +146 } +147 text := mcpToolText(t, m) +148 if !strings.HasPrefix(text, "queued: ") || len(text) <= len("queued: ") { +149 t.Fatalf("tools/call text = %q, want \"queued: \"", text) +150 } +151 if mcpIsError(m) { +152 t.Errorf("tools/call isError = true bei erfolgreicher Aufnahme: %v", m) +153 } +154 hits := fake.waitCompleted(t, 1) +155 if hits[0].Text != "hallo über mcp" { +156 t.Errorf("Fake-Treffer = %q, will die MCP-Ansage", hits[0].Text) +157 } +158 } +159 +160 // TestMCPToolsCallGesetze: muted und Gesetz-Verwürfe sind Tool-ERGEBNISSE +161 // (isError:false mit erklärendem Text), keine Protokollfehler — und die +162 // Ansage bleibt stumm. +163 func TestMCPToolsCallGesetze(t *testing.T) { +164 t.Run("muted", func(t *testing.T) { +165 root := t.TempDir() +166 fake := newFakeProvider(t, 0) +167 d := startDaemon(t, root, fake.srv.URL, nil) +168 postPath(t, d, "/mute") +169 +170 m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"speak","arguments":{"text":"stille bitte"}}}`)) +171 if mcpIsError(m) { +172 t.Fatalf("muted ist isError:true — soll Ergebnis sein: %v", m) +173 } +174 if text := mcpToolText(t, m); !strings.HasPrefix(text, "muted") { +175 t.Errorf("text = %q, will muted-Erklärung", text) +176 } +177 fake.assertNoHits(t, 300*time.Millisecond) +178 }) +179 +180 t.Run("rate_limit verwirft low", func(t *testing.T) { +181 root := t.TempDir() +182 fake := newFakeProvider(t, 0) +183 d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "30s"}) +184 +185 // Erste low-Ansage spielt (Fenster frei) und bucht es. +186 m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"speak","arguments":{"text":"erste","priority":"low"}}}`)) +187 if text := mcpToolText(t, m); !strings.HasPrefix(text, "queued: ") { +188 t.Fatalf("erste low: text = %q, want queued", text) +189 } +190 fake.waitCompleted(t, 1) +191 +192 // Zweite low innerhalb des Fensters: Verwurf als Ergebnis. +193 m = mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"speak","arguments":{"text":"zweite","priority":"low"}}}`)) +194 if mcpIsError(m) { +195 t.Fatalf("Verwurf ist isError:true — soll Ergebnis sein: %v", m) +196 } +197 if text := mcpToolText(t, m); !strings.HasPrefix(text, "discarded: rate_limit") { +198 t.Errorf("text = %q, want discarded: rate_limit …", text) +199 } +200 fake.assertNoFurtherArrivals(t, 300*time.Millisecond) +201 }) +202 +203 t.Run("quiet_hours verwirft", func(t *testing.T) { +204 root := t.TempDir() +205 fake := newFakeProvider(t, 0) +206 d := startDaemon(t, root, fake.srv.URL, map[string]string{ +207 "TALK_QUIET_HOURS": fensterUm(), +208 }) +209 +210 m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"speak","arguments":{"text":"nachts"}}}`)) +211 if mcpIsError(m) { +212 t.Fatalf("Ruhezeit-Verwurf ist isError:true — soll Ergebnis sein: %v", m) +213 } +214 if text := mcpToolText(t, m); !strings.HasPrefix(text, "discarded: quiet_hours") { +215 t.Errorf("text = %q, want discarded: quiet_hours …", text) +216 } +217 fake.assertNoHits(t, 300*time.Millisecond) +218 }) +219 } +220 +221 // TestMCPFehlerPfade: Validierungsfehler als JSON-RPC-Fehler (-32602), +222 // unbekannte Methode -32601, kaputtes JSON -32700, falsche jsonrpc-Version +223 // und Batches -32600, GET /mcp 405 (keine server-initiierten Streams). +224 func TestMCPFehlerPfade(t *testing.T) { +225 root := t.TempDir() +226 fake := newFakeProvider(t, 0) +227 d := startDaemon(t, root, fake.srv.URL, nil) +228 +229 cases := []struct { +230 name string +231 payload string +232 code float64 +233 msgPart string +234 }{ +235 {"text fehlt", `{"jsonrpc":"2.0","id":21,"method":"tools/call","params":{"name":"speak","arguments":{}}}`, -32602, "text"}, +236 {"text leer", `{"jsonrpc":"2.0","id":22,"method":"tools/call","params":{"name":"speak","arguments":{"text":" "}}}`, -32602, "text"}, +237 {"ungültige priority", `{"jsonrpc":"2.0","id":23,"method":"tools/call","params":{"name":"speak","arguments":{"text":"x","priority":"mega"}}}`, -32602, "priority"}, +238 {"unbekanntes Tool", `{"jsonrpc":"2.0","id":24,"method":"tools/call","params":{"name":"sing","arguments":{"text":"x"}}}`, -32602, "sing"}, +239 {"unbekannte Methode", `{"jsonrpc":"2.0","id":25,"method":"resources/list"}`, -32601, "resources/list"}, +240 {"kaputtes JSON", `{"jsonrpc":"2.0","id":`, -32700, "JSON"}, +241 {"falsche jsonrpc-Version", `{"jsonrpc":"1.0","id":26,"method":"ping"}`, -32600, "2.0"}, +242 {"Batch (seit 2025-06-18 entfernt)", `[{"jsonrpc":"2.0","id":27,"method":"ping"}]`, -32600, "Batch"}, +243 } +244 for _, tc := range cases { +245 t.Run(tc.name, func(t *testing.T) { +246 raw := mcpPost(t, d, tc.payload) +247 if raw.status != http.StatusOK { +248 t.Fatalf("Status = %d, want 200 (JSON-RPC-Fehler im Body)", raw.status) +249 } +250 m := mcpDecode(t, raw) +251 rpcErr, ok := m["error"].(map[string]any) +252 if !ok { +253 t.Fatalf("kein error-Objekt: %v", m) +254 } +255 if rpcErr["code"] != tc.code { +256 t.Errorf("code = %v, want %v (Body: %s)", rpcErr["code"], tc.code, raw.body) +257 } +258 if msg, _ := rpcErr["message"].(string); !strings.Contains(msg, tc.msgPart) { +259 t.Errorf("message = %q, will Teil %q", msg, tc.msgPart) +260 } +261 if _, hasResult := m["result"]; hasResult { +262 t.Errorf("error-Antwort mit result: %v", m) +263 } +264 }) +265 } +266 +267 t.Run("ping", func(t *testing.T) { +268 m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":28,"method":"ping"}`)) +269 if _, ok := m["result"]; !ok { +270 t.Fatalf("ping ohne result: %v", m) +271 } +272 }) +273 +274 t.Run("GET /mcp → 405 (keine server-initiierten Streams)", func(t *testing.T) { +275 raw := doRequest(t, http.MethodGet, d.baseURL+"/mcp", nil, "") +276 if raw.status != http.StatusMethodNotAllowed { +277 t.Fatalf("GET /mcp Status = %d, want 405", raw.status) +278 } +279 if allow := raw.header.Get("Allow"); !strings.Contains(allow, "POST") { +280 t.Errorf("Allow = %q, will POST nennen", allow) +281 } +282 }) +283 +284 t.Run("Notification mit unbekannter Methode → still 202", func(t *testing.T) { +285 raw := mcpPost(t, d, `{"jsonrpc":"2.0","method":"notifications/roots/list_changed"}`) +286 if raw.status != http.StatusAccepted || raw.body != "" { +287 t.Fatalf("Status %d Body %q, want 202 ohne Body", raw.status, raw.body) +288 } +289 }) +290 } +291 +292 // TestStatusMCPFeld: /status meldet mcp:true (Compat-Feld Task 06). +293 func TestStatusMCPFeld(t *testing.T) { +294 root := t.TempDir() +295 fake := newFakeProvider(t, 0) +296 d := startDaemon(t, root, fake.srv.URL, nil) +297 if st := getStatus(t, d); !st.MCP { +298 t.Error("mcp = false, want true (Endpoint ist vorhanden)") +299 } +300 } +301 + diff --git a/.superpowers/sdd/plan/review-30bd535..acfba19.diff b/.superpowers/sdd/plan/review-30bd535..acfba19.diff new file mode 100644 index 0000000..f86ba5a --- /dev/null +++ b/.superpowers/sdd/plan/review-30bd535..acfba19.diff @@ -0,0 +1,1286 @@ +# Review package: 30bd535..HEAD + +## Commits +acfba19 docs(readme): talk wrap dokumentieren — --Semantik, Label, Exit-Codes +6877d64 test: Prozessgrenzen-Tests für talk wrap an echter CLI + Daemon +51cd5a6 feat(cli): talk wrap — Befehlsummwicklung mit Ansage und Code-Durchreichung +671a97c fix(cli): Erstes „--“ schirmt Positionsargumente vollständig ab +a3424c3 feat(config): Ansage-Label wrap.label mit Env-Override TALK_WRAP_LABEL + +## Files changed + README.md | 47 ++++++- + cmd/talk/cli.go | 39 +++++- + cmd/talk/cli_test.go | 25 +++- + cmd/talk/daemon.go | 14 +- + cmd/talk/degraded.go | 7 +- + cmd/talk/main.go | 9 +- + cmd/talk/parse_test.go | 98 ++++++++++++++ + cmd/talk/say.go | 28 ++-- + cmd/talk/wrap.go | 195 ++++++++++++++++++++++++++++ + cmd/talk/wrap_test.go | 281 +++++++++++++++++++++++++++++++++++++++++ + internal/config/config.go | 15 +++ + internal/config/config_test.go | 52 ++++++++ + test/wrap_test.go | 168 ++++++++++++++++++++++++ + 13 files changed, 947 insertions(+), 31 deletions(-) + +## Diff +README.md --- 1/2 --- Text + 30 30 + 31 **Hinweis:** In diesem Stand sind i 31 **Hinweis:** In diesem Stand sind i + .. mplementiert: `say` (und `talk "Tex .. mplementiert: `say` (und `talk "Tex + .. t"`) .. t"`) + 32 mit der kompletten Provider-Kette u 32 mit der kompletten Provider-Kette u + .. nd Daemon-Anbindung (non-blocking ü .. nd Daemon-Anbindung (non-blocking ü + .. ber .. ber + 33 `talkd`, siehe unten), `mute`, `unm 33 `talkd`, siehe unten), `mute`, `unm + .. ute`, `status`, `cancel`. Die übrig .. ute`, `status`, `cancel` sowie `wra + .. en .. p`. + 34 Befehle sind Stubs und melden `not 34 Die übrigen Befehle sind Stubs und + .. implemented yet` (Exit-Code ≠ 0). .. melden `not implemented yet` (Exit- + .. .. Code + .. 35 ≠ 0). + 35 36 + 36 ## Daemon talkd (Task 04–06) 37 ## Daemon talkd (Task 04–06) + 37 38 + +README.md --- 2/2 --- Text +219 220 `TALK_AUDIO_SINK=null` (oder `audio.sink = "null"` in der config.toml) +220 221 schaltet jede Tongabe ab — für CI und Server. +221 222 +... 223 ## `talk wrap` — Befehle annoncieren (Task 07) +... 224 +... 225 `talk wrap -- [Argumente …]` führt den Befehl aus und macht das +... 226 Ergebnis hörbar: stdin/stdout/stderr werden durchgereicht — stdout gehört +... 227 allein dem Befehl, Informationszeilen von talk (z. B. die Item-ID der +... 228 Ansage) gehen auf stderr, damit Pipes und Redirects sauber bleiben. Die +... 229 Wandzeit wird gemessen und nach dem Ende des Befehls angesagt: +... 230 +... 231 - Erfolg: „sleep erfolgreich. Dauer: 2 Sekunden." +... 232 - Misserfolg: „sh fehlgeschlagen, Code 2." (ohne Dauer) +... 233 +... 234 ```console +... 235 talk wrap -- sleep 2 +... 236 talk wrap --wait -- make test +... 237 ``` +... 238 +... 239 - **`--` schirmt den Befehl komplett ab:** Das erste allein stehende `--` +... 240 beendet die talk-Flag-Auswertung — alles danach geht unverändert an den +... 241 Befehl. `talk wrap -- foo --bar` reicht `--bar` an foo weiter, statt es +... 242 als talk-Flag zu fehlzuinterpretieren. talk-Flags für die Ansage stehen +... 243 deshalb davor (`talk wrap --wait -- …`); ohne `--` gelten die üblichen +... 244 Regeln (Flags überall, auch nach Positionalargumenten). +... 245 - **Label** der Ansage: `wrap.label` in der config.toml (Env +... 246 `TALK_WRAP_LABEL` schlägt die Datei) oder der Basisname des Befehls +... 247 (`/usr/bin/make` → „make"). Die Sprache erkennt langdetect am Label +... 248 (de/en, deutscher Default); `--lang` überschreibt. +... 249 - **Exit-Code-Politik:** wrap exitet immer mit dem Code des Befehls — +... 250 scheitert die Ansage selbst, bleibt der Befehls-Code trotzdem (nur eine +... 251 Warning auf stderr; talk-Fehler ≠ Befehls-Fehler). Nicht startbare +... 252 Befehle (Binary nicht gefunden) zählen als 127 (Shell-Konvention), +... 253 Signaltode portabel als 1. Benutzungsfehler von talk selbst (kein +... 254 Befehl, ungültige Flags) scheitern vor dem Start des Befehls. +... 255 - **Ansageweg:** derselbe wie say — Daemon-Pfad (enqueue, non-blocking; +... 256 `--wait` respektiert), ohne Daemon Degraded Mode über die Lock-Datei. +... 257 wrap erbt die say-Flags (`--wait`, `--now`, `--priority`, `--voice`, +... 258 `--lang`, `--provider`) und reicht sie an die Ansage durch. +... 259 +... 260 ```toml +... 261 [wrap] +... 262 label = "Deployment" # Ansage-Label statt Befehls-Basisname (TALK_WRAP_LABEL) +... 263 ``` +... 264 +222 265 ## Entwicklung +223 266 +224 267 ```console + +cmd/talk/cli.go --- 1/3 --- Go + 5 5 "flag" + 6 6 "fmt" + 7 7 "io" + . 8 "slices" + 8 9 ) + 9 10 + 10 11 // Exit-Codes. + +cmd/talk/cli.go --- 2/3 --- Go + 44 name: "wrap", 45 name: "wrap", + 45 short: "Befehl ausführen u 46 short: "Befehl ausführen + .. nd Ergebnis ansagen", .. und Ergebnis ansagen", + 46 args: "-- [Argum 47 args: "-- [Arg + .. ente …]", .. umente …]", + 47 detail: "Führt den Befehl a 48 detail: "Führt den Befehl + .. us (stdin/stdout/stderr werden durc .. aus (stdin/stdout/stderr werden du + .. hgereicht) und spricht danach Erfol .. rchgereicht) und spricht danach Erf + .. g bzw. Misserfolg und Dauer.", .. olg bzw. Misserfolg und Dauer. Das + .. .. erste „--\" schirmt den Befehl komp + .. .. lett von den talk-Flags ab: `talk w + .. .. rap -- foo --bar` reicht --bar an f + .. .. oo weiter. Der Exit-Code des Befehl + .. .. s wird durchgereicht; scheitert die + .. .. Ansage selbst, bleibt er trotzdem. + .. .. Label der Ansage: config `wrap.lab + .. .. el` oder der Basisname des Befehls. + .. .. wrap erbt die say-Flags (--wait, - + .. .. -now, --priority, --voice, --lang, + .. .. --provider).", + .. 49 register: addSayFlags, + .. 50 handler: runWrap, + 48 }, 51 }, + 49 { 52 { + 50 name: "mute", 53 name: "mute", + +cmd/talk/cli.go --- 3/3 --- Go +151 } 154 } +152 155 +153 // parseInterspersed parst Flags, d 156 // parseInterspersed parst Flags, d +... ie überall in argv stehen dürfen. D ... ie überall in argv stehen dürfen. D +... ie ... ie +154 // Go-stdlib-flag stoppt beim erste 157 // Go-stdlib-flag stoppt beim erste +... n Nicht-Flag-AArgument; die CLI-Sur ... n Nicht-Flag-Argument; die CLI-Surf +... face ... ace +155 // verlangt aber `talk "Text" --pro 158 // verlangt aber `talk "Text" --pro +... vider=x` (Abnahmekriterium Task 03) ... vider=x` (Abnahmekriterium Task 03) +156 // genauso wie `talk --provider=x " 159 // genauso wie `talk --provider=x " +... Text"`. Trick: Parse über den Rest ... Text"`. Trick: Parse über den Rest +157 // wiederholen und jeweils das Posi 160 // wiederholen und jeweils das Posi +... tionsargument vor dem Weiterparsen ... tionsargument vor dem Weiterparsen +158 // entnehmen. Fehler (unbekannte Fl 161 // entnehmen. Fehler (unbekannte Fl +... ags, -h) kommen wie gewohnt vom Fla ... ags, -h) kommen wie gewohnt vom Fla +... gSet. ... gSet. +... 162 // +... 163 // Seit Task 07 schirmt das erste „ +... ... --" (als eigenständiges Argument) a +... ... lles +... 164 // dahinter vollständig ab — die St +... ... dlib-Terminator-Semantik, die der +... 165 // Interspersed-Loop zuvor aushebel +... ... te: Er nahm nach jedem Positionsarg +... ... ument +... 166 // das Parsen wieder auf und fraß d +... ... amit Flags, die für den NACHFOLGEND +... ... EN +... 167 // Befehl gemeint waren (`talk wrap +... ... -- foo --bar` wäre an --bar als +... 168 // vermeintlichem talk-Flag geschei +... ... tert). Jetzt wird argv am ersten „- +... ... -" +... 169 // getrennt: Nur der Teil davor wir +... ... d interspersed geparst, der Rest is +... ... t +... 170 // bedingungslos Positionsargument +... ... — für say (`talk -- "Text --flag"`) +... 171 // genauso wie für wrap (`talk wrap +... ... -- foo --bar`). Die Abnahme-Form +... 172 // `talk "Text" --provider=x` (Flag +... ... nach dem Text, ohne „--") ist davo +... ... n +... 173 // unberührt. Ein „--" als Flag-Wer +... ... t (unmittelbar nach --voice) trifft +... 174 // diese Trennung nicht, weil nur e +... ... in allein stehendes „--" trennt. +159 func parseInterspersed(fs *flag.Fla 175 func parseInterspersed(fs *flag.Fla +... gSet, argv []string) ([]string, err ... gSet, argv []string) ([]string, err +... or) { ... or) { +... 176 if i := slices.Index(argv, "--" +... ... ); i >= 0 { +... 177 positional, err := parseInt +... ... erspersedLoop(fs, argv[:i]) +... 178 if err != nil { +... 179 return nil, err +... 180 } +... 181 return append(positional, a +... ... rgv[i+1:]...), nil +... 182 } +... 183 return parseInterspersedLoop(fs +... ... , argv) +... 184 } +... 185 +... 186 // parseInterspersedLoop ist der In +... ... terspersed-Durchlauf — argv enthält +... ... kein +... 187 // allein stehendes „--" mehr (pars +... ... eInterspersed hat davor getrennt). +... 188 func parseInterspersedLoop(fs *flag +... ... .FlagSet, argv []string) ([]string, +... ... error) { +160 var positional []string 189 var positional []string +161 rest := argv 190 rest := argv +162 for { 191 for { + +cmd/talk/cli_test.go --- 1/2 --- Go + 15 } 15 } + 16 16 + 17 // wantStubCommands ist wantSubcomm 17 // wantStubCommands ist wantSubcomm + .. ands ohne die implementierten Befeh .. ands ohne die implementierten Befeh + .. le — .. le — + 18 // say (Task 02/04) sowie mute/unmu 18 // say (Task 02/04), mute/unmute/st + .. te/status/cancel (Task 04, Daemon- .. atus/cancel (Task 04, Daemon-Aufruf + .. .. e) + .. 19 // und wrap (Task 07, Befehlsummich + .. .. tung mit Ansage) sind real; die + 19 // Aufrufe) sind real; die restlich 20 // restlichen bleiben Stubs bis zu + .. en bleiben Stubs bis zu ihren Tasks .. ihren Tasks. + .. . .. + 20 var wantStubCommands = []string{ 21 var wantStubCommands = []string{ + 21 "wrap", "voices", "test", "doct 22 "voices", "test", "doctor", "se + .. or", "setup", .. tup", + 22 } 23 } + 23 24 + 24 // runCLI ist der Test-Einstieg in 25 // runCLI ist der Test-Einstieg in + .. run mit leerem stdin (kein Text übe .. run mit leerem stdin (kein Text übe + .. r .. r + +cmd/talk/cli_test.go --- 2/2 --- Go +106 107 } +107 108 } +... 109 +... 110 // TestWrapHelpZeigtSayFlags: wrap erbt die say-Flags (mindestens --wait) +... 111 // und ist kein Stub mehr. +... 112 func TestWrapHelpZeigtSayFlags(t *testing.T) { +... 113 var out, errBuf bytes.Buffer +... 114 code := runCLI([]string{"wrap", "--help"}, &out, &errBuf) +... 115 if code != exitOK { +... 116 t.Fatalf("talk wrap --help: Exit-Code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String()) +... 117 } +... 118 for _, f := range []string{"-wait", "-provider"} { +... 119 if !strings.Contains(out.String(), f) { +... 120 t.Errorf("talk wrap --help zeigt Flag %s nicht (Output: %q)", f, out.String()) +... 121 } +... 122 } +... 123 if strings.Contains(out.String(), "noch nicht implementiert") { +... 124 t.Errorf("talk wrap --help soll wrap nicht als Stub markieren: %q", out.String()) +... 125 } +... 126 } +108 127 +109 128 func TestHelpSubcommand(t *testing.T) { +110 129 t.Run("ohne Argument", func(t *testing.T) { + +cmd/talk/daemon.go --- 1/3 --- Go +24 24 +25 // sayViaDaemon ist der Daemon-Pfad 25 // sayViaDaemon ist der Daemon-Pfad +.. von say: Erkennung über GET /status, .. von say: Erkennung über GET /status, +26 // dann enqueue (POST /speak). Defau 26 // dann enqueue (POST /speak). Defau +.. lt: sofort zurückkehren (non-blockin .. lt: sofort zurückkehren (non-blockin +.. g), .. g), +27 // stdout erhält die Item-ID; --wait 27 // info erhält die Item-ID; --wait p +.. pollt /status, bis die Ansage weder .. ollt /status, bis die Ansage weder +28 // läuft noch wartet. Bei aktivem Mu 28 // läuft noch wartet. Bei aktivem Mu +.. te verwirft der Daemon die Ansage — .. te verwirft der Daemon die Ansage — +29 // stdout sagt „muted". 29 // info sagt „muted". info ist der W +.. .. riter für Informations-Token (Item-I +.. .. D, +.. 30 // muted, discarded): say reicht std +.. .. out, wrap stderr durch — stdout eine +.. .. s +.. 31 // gewrappten Befehls darf nicht um +.. .. talk-Zeilen verunreinigt werden. +30 // 32 // +31 // handled=false heißt: kein Daemon 33 // handled=false heißt: kein Daemon +.. erreichbar → der Aufrufer geht in de .. erreichbar → der Aufrufer geht in de +.. n .. n +32 // Degraded Mode. Stirbt der Daemon 34 // Degraded Mode. Stirbt der Daemon +.. zwischen Erkennung und enqueue .. zwischen Erkennung und enqueue +33 // (Netz-Fehler), gilt dasselbe; ein 35 // (Netz-Fehler), gilt dasselbe; ein +.. e Daemon-Antwort mit Fehlerstatus .. e Daemon-Antwort mit Fehlerstatus +34 // (z. B. 400) wird dagegen angezeig 36 // (z. B. 400) wird dagegen angezeig +.. t — die Ansage war beim Daemon .. t — die Ansage war beim Daemon +35 // angekommen und wurde bewusst abge 37 // angekommen und wurde bewusst abge +.. lehnt. .. lehnt. +36 func sayViaDaemon(cfg config.Config, 38 func sayViaDaemon(cfg config.Config, +.. text string, opts *sayOptions, stdo .. text string, opts *sayOptions, info +.. ut, stderr io.Writer) (code int, han .. , stderr io.Writer) (code int, handl +.. dled bool) { .. ed bool) { +37 client := api.NewClient(cfg.Serv 39 client := api.NewClient(cfg.Serv +.. er.Port) .. er.Port) +38 40 +39 probeCtx, cancelProbe := context 41 probeCtx, cancelProbe := context +.. .WithTimeout(context.Background(), d .. .WithTimeout(context.Background(), d +.. aemonProbeTimeout) .. aemonProbeTimeout) + +cmd/talk/daemon.go --- 2/3 --- Go +61 return 0, false 63 return 0, false +62 } 64 } +63 if resp.Muted { 65 if resp.Muted { +64 fmt.Fprintln(stdout, "muted" 66 fmt.Fprintln(info, "muted") +.. ) .. +65 fmt.Fprintln(stderr, "talk: 67 fmt.Fprintln(stderr, "talk: +.. stumm geschaltet (mute) — Ansage ver .. stumm geschaltet (mute) — Ansage ver +.. worfen") .. worfen") +66 return exitOK, true 68 return exitOK, true +67 } 69 } + +cmd/talk/daemon.go --- 3/3 --- Go +73 if resp.Toast { 75 if resp.Toast { +74 line += " (toast)" 76 line += " (toast)" +75 } 77 } +76 fmt.Fprintln(stdout, line) 78 fmt.Fprintln(info, line) +77 fmt.Fprintln(stderr, "talk: 79 fmt.Fprintln(stderr, "talk: +.. "+discardHint(resp.Discarded, resp.T .. "+discardHint(resp.Discarded, resp.T +.. oast)) .. oast)) +78 return exitOK, true 80 return exitOK, true +79 } 81 } +80 fmt.Fprintln(stdout, resp.ID) 82 fmt.Fprintln(info, resp.ID) +81 if !opts.wait { 83 if !opts.wait { +82 return exitOK, true 84 return exitOK, true +83 } 85 } + +cmd/talk/degraded.go --- Go +31 // dann Spracherkennung → Provider-K 31 // dann Spracherkennung → Provider-K +.. ette → Sink — identische Kette wie i .. ette → Sink — identische Kette wie i +.. m .. m +32 // Daemon, nur im CLI-Prozess (block 32 // Daemon, nur im CLI-Prozess (block +.. ierend; ohne Daemon ist --wait das .. ierend; ohne Daemon ist --wait das +33 // Default-Verhalten). Best-effort: 33 // Default-Verhalten). Best-effort: +.. Ohne Config-Dir läuft es ohne Lock u .. Ohne Config-Dir läuft es ohne Lock u +.. nd .. nd +34 // Mute weiter, statt zu scheitern. 34 // Mute weiter, statt zu scheitern. +.. .. info ist der Writer für Informations +.. .. - +.. 35 // Token („muted"): say reicht stdou +.. .. t, wrap stderr durch. +35 func sayDegraded(cfg config.Config, 36 func sayDegraded(cfg config.Config, +.. text string, opts *sayOptions, stdou .. text string, opts *sayOptions, info, +.. t, stderr io.Writer) int { .. stderr io.Writer) int { +36 if dir, err := config.Dir(getenv 37 if dir, err := config.Dir(getenv +.. ); err == nil { .. ); err == nil { +37 mute := queue.MuteStore{Path 38 mute := queue.MuteStore{Path +.. : filepath.Join(dir, queue.MuteFileN .. : filepath.Join(dir, queue.MuteFileN +.. ame)} .. ame)} +38 if mute.Muted() { 39 if mute.Muted() { +39 fmt.Fprintln(stdout, "mu 40 fmt.Fprintln(info, "mute +.. ted") .. d") +40 fmt.Fprintln(stderr, "ta 41 fmt.Fprintln(stderr, "ta +.. lk: stumm geschaltet (mute) — Ansage .. lk: stumm geschaltet (mute) — Ansage +.. verworfen") .. verworfen") +41 return exitOK 42 return exitOK +42 } 43 } + +cmd/talk/main.go --- Go + 1 // talk ist die CLI für Sprach-Ausga 1 // talk ist die CLI für Sprach-Ausga + . ben (talk 2.0). . ben (talk 2.0). + 2 // 2 // + 3 // Dieser Stand (Task 04): say (und 3 // Dieser Stand (Task 07): say (und + . der Default-Aufruf `talk "Text"`) re . der Default-Aufruf `talk "Text"`) re + . icht . icht + 4 // Ansagen in den Daemon talkd ein — 4 // Ansagen in den Daemon talkd ein — + . non-blocking, --wait pollt bis zur . non-blocking, --wait pollt bis zur + 5 // Fertigstellung, --now springt vor 5 // Fertigstellung, --now springt vor + . . Ohne Daemon fällt say in den Degra . . Ohne Daemon fällt say in den Degra + . ded . ded + 6 // Mode (Lock-Datei, sequenziell lok 6 // Mode (Lock-Datei, sequenziell lok + . al, mute respektiert). mute/unmute/ . al, mute respektiert). mute/unmute/ + 7 // status/cancel sind Daemon-Aufrufe 7 // status/cancel sind Daemon-Aufrufe + . mit plain Output. Die übrigen Befeh . mit plain Output. `talk wrap -- ` + . 8 // führt Befehle aus (stdin/stdout/s + . . tderr durchgereicht, Exit-Code + . 9 // durchgereicht) und sagt danach Er + . . folg/Misserfolg und Dauer an. Die + 8 // sind Stubs; TUI und weitere Aufga 10 // übrigen Befehle sind Stubs; TUI u + . ben folgen in den Tasks 05–09. .. nd weitere Aufgaben folgen in den + . 11 // Tasks 08–09. + 9 package main 12 package main +10 13 +11 import "os" 14 import "os" + +cmd/talk/parse_test.go --- Go + 1 package main + 2 + 3 import ( + 4 "flag" + 5 "io" + 6 "strings" + 7 "testing" + 8 ) + 9 + 10 // TestParseInterspersedDoubleDash (Regression, Task 07): Das erste allein + 11 // stehende „--" schirmt alles Folgenden von der Flag-Auswertung ab — der + 12 // Kern der wrap-Aufrufform `talk wrap -- foo --bar`. Vorher nahm der + 13 // Interspersed-Loop nach jedem Positionsargument das Parsen wieder auf und + 14 // fraß damit Flags des nachfolgenden Befehls. Gleichzeitig bleibt die + 15 // Abnahme-Form „Flag nach dem Text" (ohne „--") intakt. + 16 func TestParseInterspersedDoubleDash(t *testing.T) { + 17 newWrapFS := func() (*flag.FlagSet, *sayOptions) { + 18 fs := flag.NewFlagSet("talk wrap", flag.ContinueOnError) + 19 fs.SetOutput(io.Discard) + 20 fs.Usage = func() {} + 21 return fs, addSayFlags(fs) + 22 } + 23 + 24 t.Run("Doppelminus schirmt Befehls-Flags", func(t *testing.T) { + 25 fs, opts := newWrapFS() + 26 positional, err := parseInterspersed(fs, []string{"--", "foo", "--bar"}) + 27 if err != nil { + 28 t.Fatalf("parse: %v", err) + 29 } + 30 if strings.Join(positional, " ") != "foo --bar" { + 31 t.Errorf("Positionsargumente = %q, want %q", positional, []string{"foo", "--bar"}) + 32 } + 33 if opts.wait { + 34 t.Error("--bar darf kein talk-Flag geworden sein (Shielding kaputt)") + 35 } + 36 }) + 37 + 38 t.Run("talk-Flags vor dem Doppelminus gelten", func(t *testing.T) { + 39 fs, opts := newWrapFS() + 40 positional, err := parseInterspersed(fs, []string{"--wait", "--", "sleep", "1"}) + 41 if err != nil { + 42 t.Fatalf("parse: %v", err) + 43 } + 44 if !opts.wait { + 45 t.Error("--wait vor dem -- muss als talk-Flag gelten") + 46 } + 47 if strings.Join(positional, " ") != "sleep 1" { + 48 t.Errorf("Positionsargumente = %q, want %q", positional, []string{"sleep", "1"}) + 49 } + 50 }) + 51 + 52 t.Run("Positional vor dem Doppelminus wird mitgeschleppt", func(t *testing.T) { + 53 fs, _ := newWrapFS() + 54 positional, err := parseInterspersed(fs, []string{"foo", "--", "--bar"}) + 55 if err != nil { + 56 t.Fatalf("parse: %v", err) + 57 } + 58 if strings.Join(positional, " ") != "foo --bar" { + 59 t.Errorf("Positionsargumente = %q, want %q", positional, []string{"foo", "--bar"}) + 60 } + 61 }) + 62 + 63 t.Run("abgeschirmtes talk-Flag setzt den Flag nicht", func(t *testing.T) { + 64 fs, opts := newWrapFS() + 65 positional, err := parseInterspersed(fs, []string{"--", "--wait", "true"}) + 66 if err != nil { + 67 t.Fatalf("parse: %v", err) + 68 } + 69 if opts.wait { + 70 t.Error("--wait NACH dem -- gehört dem Befehl, nicht talk") + 71 } + 72 if strings.Join(positional, " ") != "--wait true" { + 73 t.Errorf("Positionsargumente = %q, want %q", positional, []string{"--wait", "true"}) + 74 } + 75 }) + 76 + 77 t.Run("Flag nach dem Text ohne Doppelminus bleibt erlaubt", func(t *testing.T) { + 78 fs, opts := newWrapFS() + 79 positional, err := parseInterspersed(fs, []string{"Text", "--lang=en"}) + 80 if err != nil { + 81 t.Fatalf("parse: %v (Abnahme-Form Flag-nach-Text kaputt)", err) + 82 } + 83 if strings.Join(positional, " ") != "Text" || opts.lang != "en" { + 84 t.Errorf("Positionsargumente = %q, lang = %q — Flag-nach-Text muss weitergehen", positional, opts.lang) + 85 } + 86 }) + 87 + 88 t.Run("Text nach dem Doppelminus kommt vollständig durch (say)", func(t *testing.T) { + 89 fs, _ := newWrapFS() + 90 positional, err := parseInterspersed(fs, []string{"--", "Bericht", "mit --flag", "fertig"}) + 91 if err != nil { + 92 t.Fatalf("parse: %v", err) + 93 } + 94 if strings.Join(positional, " ") != "Bericht mit --flag fertig" { + 95 t.Errorf("Positionsargumente = %q, want Wörter unverändert", positional) + 96 } + 97 }) + 98 } + 99 + +cmd/talk/say.go --- 1/2 --- Go +53 } 53 } +54 } 54 } +55 55 +56 // Validierung vor jedem Daemon- 56 // Validierung vor jedem Daemon- +.. Kontakt: Benutzungsfehler sollen .. Kontakt (auch wrap erbt die say-Flag +.. .. s): +57 // schnell und ohne Netzwerk-Umw 57 // Benutzungsfehler sollen schne +.. ege auffallen. .. ll und ohne Netzwerk-Umwege auffalle +.. .. n. +58 if opts.lang != "" && opts.lang .. +.. != "de" && opts.lang != "en" { .. +59 fmt.Fprintf(stderr, "talk: - .. +.. -lang: ungültiger Wert %q (erlaubt: .. +.. de, en)\n", opts.lang) .. +60 return exitFailure .. +61 } .. +62 if opts.priority != "" && !queue 58 if code, bad := validateSayOptio +.. .Priority(opts.priority).Valid() { .. ns(opts, stderr); bad { +63 fmt.Fprintf(stderr, "talk: - .. +.. -priority: ungültiger Wert %q (erlau .. +.. bt: low, normal, high)\n", opts.prio .. +.. rity) .. +64 return exitFailure 59 return code +65 } 60 } +66 61 +67 cfg, err := config.Load(config.O 62 cfg, err := config.Load(config.O +.. ptions{Getenv: getenv}) .. ptions{Getenv: getenv}) + +cmd/talk/say.go --- 2/2 --- Go +78 73 return sayDegraded(cfg, text, opts, stdout, stderr) +79 74 } +.. 75 +.. 76 // validateSayOptions prüft die say-Flags (wrap erbt sie): --lang auf de | +.. 77 // en, --priority auf low | normal | high. Rückgabe (code, true) heißt: +.. 78 // Benutzungsfehler, bereits auf stderr gemeldet. +.. 79 func validateSayOptions(opts *sayOptions, stderr io.Writer) (int, bool) { +.. 80 if opts.lang != "" && opts.lang != "de" && opts.lang != "en" { +.. 81 fmt.Fprintf(stderr, "talk: --lang: ungültiger Wert %q (erlaubt: de, en)\n", opts.lang) +.. 82 return exitFailure, true +.. 83 } +.. 84 if opts.priority != "" && !queue.Priority(opts.priority).Valid() { +.. 85 fmt.Fprintf(stderr, "talk: --priority: ungültiger Wert %q (erlaubt: low, normal, high)\n", opts.priority) +.. 86 return exitFailure, true +.. 87 } +.. 88 return 0, false +.. 89 } +80 90 +81 91 // pickProvider löst den Provider-Pin auf: CLI-Flag --provider schlägt +82 92 // `provider = "…"` aus der config.toml; leer = komplette Default-Kette. + +cmd/talk/wrap.go --- Go + 1 package main + 2 + 3 import ( + 4 "errors" + 5 "fmt" + 6 "io" + 7 "os/exec" + 8 "path/filepath" + 9 "strings" + 10 "time" + 11 + 12 "code.m3ta.dev/m3tam3re/talk/internal/config" + 13 "code.m3ta.dev/m3tam3re/talk/internal/langdetect" + 14 ) + 15 + 16 // Kind nicht gefunden — Shell-Konvention 127. + 17 const exitNotFound = 127 + 18 + 19 // runWrap ist der wrap-Eingangspfad (Task 07): `talk wrap -- + 20 // [Argumente …]` führt den Befehl aus — stdin/stdout/stderr werden + 21 // durchgereicht, stdout gehört allein dem Befehl —, misst die Wandzeit und + 22 // sagt danach das Ergebnis an: „