diff --git a/.specs/2026-10-29-live-captions.md b/.specs/2026-10-29-live-captions.md index 16c5b52..207e47e 100644 --- a/.specs/2026-10-29-live-captions.md +++ b/.specs/2026-10-29-live-captions.md @@ -1,8 +1,8 @@ # open_mess - napisy na żywo ze spotkań Teams (wycinek etapu 3) Data: 2026-10-29 -Status: po 11 rundach (R11 dodatkowa za zgodą właściciela): GLM APPROVE w R9-R11, Codex REJECT w R11 - poprawione, bez ponownej recenzji -Powiązane: `.specs/2026-10-26-open-mess-design.md` (8.3, 8.5, 8.6, 12, 15), `.specs/2026-10-28-meetings-transcripts.md` (5.1, sekcja 10 D2), `docs/protocols/teams.md` (sekcja 5 Trouter, sekcja 10 napisy, "Needs live capture" 5, 7, 8, 17-19), `wit/module.wit`. +Status: po 11 rundach (R11 dodatkowa za zgodą właściciela) i po krokach 0a i 0b (2026-10-30: sonda odebrała napisy na żywo); drugi cykl recenzji po zmianach z kroku 0b: R12-R18 (R18 za zgodą właściciela), GLM APPROVE w R13 i R15-R19; krok 0c zrobiony 2026-11-02 (Live results 18): żadna bramka nie zadziałała, oferta to sam kanał danych; R19 za zgodą właściciela (zmiany z R18 i po kroku 0c): Codex i GLM APPROVE bez uwag - spec zatwierdzony; kroki 1, 1b, 2 i 3 zrobione (2026-11-03), kroki 4 i 5 zrobione (2026-11-04); czat spotkania w kontekście spotkania (decyzja właściciela z 2026-11-03, 4.5) po R20-R25: Codex i GLM APPROVE w R25 +Powiązane: `.specs/2026-10-26-open-mess-design.md` (8.3, 8.5, 8.6, 12, 15), `.specs/2026-10-28-meetings-transcripts.md` (5.1, sekcja 10 D2), `docs/protocols/teams.md` (sekcja 5 Trouter, sekcja 10 napisy, "Needs live capture" 5, 7, 8, 17-19, "Live results" 11 i 13-18), `wit/module.wit`. ## 1. Cel @@ -19,22 +19,37 @@ Poza zakresem: agent mówiący lub piszący na spotkaniu, nagrywanie, dźwięk i - 2026-10-29, O3 = (b): open_mess tylko czyta napisy, które ktoś już włączył (np. właściciel w oficjalnym kliencie), i nigdy ich nie włącza: nie dodaje bota napisów i nie wysyła mu żadnego polecenia. -Otwarta decyzja - sekcja 10 (O2). +- 2026-10-30, krok 0b: właściciel pozwolił testować sondą także na swoim spotkaniu ze znajomymi (dziewięć-dziesięć innych osób), za zgodą w czacie na każdą serię; jeden przebieg diagnostyczny z audio `sendrecv` bez ścieżki (usługa wysłała dźwięk, sonda go odrzuciła bez dekodowania) był jego decyzją dla sondy - produkt nigdy tak nie robi (4.4). + +- 2026-11-03: "pamietaj jeszcze że powinniśmy brać treść czata ze spotkań do kontekstu spotkania". Kontekst spotkania dla agenta to napisy i czat spotkania (wątek `19:meeting_...@thread.v2`), bez drugiego czytnika czatu: `teams_list_meetings` daje id wątku, a `read_messages` i `search` już go czytają. Treść czatu to dane innych osób, pod `untrusted`, jak napisy. Jak to spiąć (np. `teams_read_captions` zwraca też nowe wiadomości czatu od kursora albo tylko wskazuje wątek dla `read_messages`), rozstrzyga zmiana specu przed krokiem 4, z recenzją Codex i GLM. Rozstrzygnięte w 4.5: czat czyta `read_messages` tej samej rozmowy, a opisy narzędzi mówią o tym agentowi. + +Otwartych decyzji nie ma (O2 bez decyzji po kroku 0c - sekcja 10). ## 3. Kontekst w kodzie i protokole -Kod (sprawdzony w repo): +Kod (sprawdzony w repo przed krokiem 1; co zmieniły kroki 1-3, mówi changelog rund): - `wit/module.wit`: moduł sięga świata tylko przez importy `http`, `websocket`, `storage`, `secrets`, `sign-in`, `host`; zdarzenia z gniazd i timerów przychodzą przez eksport `events` (`on-ws-message`, `on-ws-closed`, `on-timer`). Nie ma importu dla UDP ani WebRTC. - `crates/open-mess-host/src/ws.rs`: rdzeń trzyma połączenia WebSocket modułu, każdą ramkę przychodzącą oddaje modułowi krótkim wywołaniem `events`; wzorzec dla nowego importu (5.2). `crates/open-mess-host/src/net.rs` i `ip_policy.rs`: lista hostów modułu i odmowa adresów prywatnych. - `crates/open-mess/src/tools/extra.rs` (etap 2): narzędzia modułu z `writes: true` nie są wystawiane ("not supported yet: module write tools"), bo zatwierdzenie (spec 8.6) wymaga opisu celu, którego `tool-decl` nie ma. Ten spec to dodaje (5.1). - `crates/open-mess/src/pending.rs`: zapisy (`Kind::Send`, `Edit`, `React`) jako akcje: `create` pod prawem zapisu (`never` / `confirm` / `autonomous`), przy `confirm` opis celu z `describe-target` i odcisk (uczestnicy, tekst), przed wykonaniem ponowny opis i porównanie odcisku; `run` woła `handle.call(account, kind.tool(), args)`. `crates/open-mess/src/approval.rs` buduje okno zgody z `Preview`. `crates/open-mess/src/tools/mod.rs` (`Gate`) blokuje zapisy w trakcie aktualizacji przez `Kind::for_tool`. - `modules/teams/src/`: czat i odpytywanie (`poll.rs`, co 60 s) bez Trouter; etap 1 Trouter odłożył (docs 5, "Needs live capture" 5, 7, 8). `api.rs` daje tokeny WAM, token Skype i katalog `regionGtms` (etap 2), `manifest.rs` hosty i zakresy, `meetings.rs` wątek `19:meeting_...` z kalendarza. -Protokół (`docs/protocols/teams.md` sekcja 10, analiza statyczna zainstalowanego klienta, bez sprawdzenia na żywo): -- Napisy daje bot po stronie serwera (MRI `28:...`, ten sam co nagrywanie i transkrypcja), który jest uczestnikiem połączenia. Włączenie: bot dodany do połączenia, jeśli go nie ma, albo polecenie HTTP POST na jego `commandUrl` z wpisu w liście uczestników (`endpointMetadata`), treść `{action: "start", mode: "transcription", processingModes: [ClosedCaptions], participantMri, participantLegId, participantSkypeToken, ...}`, z tokenem Skype i AAD. -- Tekst przychodzi kanałem danych połączenia (modalność mediów), identyfikator danych 3 (`DATA_ID_TRANSCRIPT`), jako JSON `recognitionResults[]` / `textTracks[]` z `userId` (MRI), `displayName`, `text`, `isFinal`, `spokenLanguage`, czasami. Sygnalizacja i Trouter niosą tylko wpis bota i polecenia; dołączenie tylko do sygnalizacji ("subscribe", `joinForRosterOnly`) nie ma kanału danych. -- Dołączenie: `JoinConversation` do usługi rozmów (adres z ECS w czasie działania, nieznany statycznie) z ofertą SDP; usługa woła klienta zwrotnie na adresy budowane na URL Trouter (zapas: broker). Klient sam dołącza z `datachannelDirection` Bidirectional przy `audioDirection` Inactive i `videoDirection` Disabled, więc napisy mogą dotrzeć do urządzenia bez dźwięku i obrazu - przez wynegocjowaną sesję mediów (ICE, DTLS, SCTP). Przeglądarkowy stos klienta (`calling-pluginless`) używa standardowego WebRTC (`createDataChannel("main-channel")`). -- Widoczność: oficjalny klient użytkownika pokazuje "You're in this meeting on another device" i "Transfer to this device" / "Add this device" (CONFIRMED); co widzą inni - nieznane (prawdopodobnie jeden wpis z dwoma urządzeniami). +Protokół (`docs/protocols/teams.md`: sekcja 10 to analiza statyczna, krok 0a; "Live results" 11 i 13-16 to sprawdzenie na żywo sondą poza repo, krok 0b, 2026-10-30): +- Napisy daje bot po stronie serwera (MRI `28:...`, ten sam co nagrywanie i transkrypcja), który jest uczestnikiem połączenia; włącza go oficjalny klient (polecenie na `commandUrl` bota) - open_mess nigdy (O3). Lista uczestników przychodzi zwrotnie na `/conversation/rosterUpdate/`; napisy są włączone, gdy `endpointMetadata.processingModes.closedCaptions.state` bota to `Active` (docs 10.7; na żywo widziane `Active`, Live results 15). +- Tekst przychodzi kanałem danych połączenia `main-channel` (SCTP), identyfikator danych 3, z 11-bajtowym nagłówkiem z docs 10.7 (na żywo 0 złych ramek na 356 wiadomości), jako JSON z kluczami `events`, `recognitionResults`, `version`; wynik ma `confidence`, `displayName`, `duration`, `id`, `isFinal`, `model`, `source`, `speechRecognitionServiceLatency`, `spokenLanguage`, `temporaryStorageOnly`, `text`, `timestampAudioSent`, `trackLanguage`, `userId` (`textTracks` jest tylko w analizie statycznej, na żywo go nie było). Na kanale nie trzeba niczego wysyłać (CONFIRMED na żywo). +- Dołączenie, które na żywo dało napisy (Live results 14-16), po kolei: + 1. Trouter v4c: `wss://go.trouter.teams.microsoft.com/v4/c?...`, po `1::` zdarzenie `user.authenticate` z tokenem IC3 (`https://ic3.teams.office.com`, ten sam co czat); odpowiedź `trouter.connected` z `surl` (adres zwrotny), `registrarUrl`, `reconnectUrl`, `ttl` (około tygodnia). + 2. Rejestracja urządzenia w `registrarUrl` (`POST .../registrar/prod/V2/registrations`, HTTP 202, TTL 900 s, `surl` jako jedyny transport), usuwana na końcu (`DELETE`, 202). Po rejestracji Trouter dostarcza też powiadomienia czatu (`/messaging`) - moduł je pomija. Czy rejestracja jest potrzebna do dołączenia - nie sprawdzono; moduł robi to, co klient. + 3. Adres trwającego połączenia (`conversationController`), dwie drogi: + - (a) `POST https://api-.flightproxy.teams.microsoft.com/api/v2/epconv` bez `callInvitation`, z `meetingData` (`meetingCode`, `passcode`, `meetingUrl` z linku `/meet/?p=`) i `endpointCapabilities` 72935 (z 1088 usługa tworzy nową, pustą rozmowę - HTTP 201). Tą drogą przyszły napisy. + - (b) Magazyn połączeń użytkownika (`GET .../api.userstore.skype.com/api/v1/user//conversations`): pozycja z `groupContext.threadId` równym wątkowi spotkania niesie `conversationController`. Dołączenie na ten adres dało kanał danych i napisy (krok 0c, Live results 18) - bez `epconv` i bez `notificationLinks`. Na zaplanowanym spotkaniu z kalendarza 2026-11-02 pozycja miała `groupContext.threadId` równy wątkowi spotkania, a jej `conversationController` był tym samym adresem, który podaje stan połączenia w czacie spotkania (Live results 18); na zaplanowanym spotkaniu 2026-10-29 pozycje nie miały `groupContext` (Live results 11), a na spotkaniach "Meet now" i rozmowach czatu grupowego 2026-10-30 miały (Live results 13). Brak pasującej pozycji kończy się czytelnym błędem przed dołączeniem (4.1), nie złym dołączeniem. + 4. Tylko droga (a): `POST /notificationLinks` (HTTP 200) - przenosi wywołania zwrotne z brokera na Trouter. Drogą (b) napisy przyszły bez tego kroku (Live results 18), więc moduł go nie wysyła. + 5. Dołączenie: `POST `, ciało bez koperty `payload` (z kopertą usługa daje 503, subCode 70051), z `callInvitation.mediaContent` (`application/sdp-ngc-1.0`, oferta w `blob`) i jednym `mediaLegId`, `endpointState.state.isMuted: true`; HTTP 200, potem zwrotnie przez Trouter `/call/acceptance/` z odpowiedzią SDP, potwierdzane `{"callAcceptanceAcknowledgement": {"links": {}}}` (202). Opuszczenie: `POST links.leave` (204). Nagłówki jak u klienta webowego (Live results 14) z uczciwym `User-Agent` i bez `Origin` - open_mess nie udaje przeglądarki; HTTP/2. +- SDP w formacie Microsoft (MS-SDP) w obie strony, jak konwerter klienta: kanał danych jako `m=x-data RTP/SAVP 127 126` z `a=rtpmap:127 x-data/90000`, `a=rtpmap:126 rtx/90000`, `a=fmtp:126 apt=127`, `a=x-data-protocol:sctp`, `a=label:data` (bez tych trzech linii usługa odrzuca sekcję portem 0); odpowiedź wraca do `m=application ... UDP/DTLS/SCTP webrtc-datachannel`. Oferta z samym kanałem danych (bez sekcji audio) daje napisy (krok 0c, Live results 18). Działała też oferta z audio `sendonly` bez ścieżki i sekcją danych w jednym BUNDLE (krok 0b; usługa odpowiada `recvonly`), a przy audio `inactive` usługa odpowiedziała, ale ICE się nie połączyło (jedna próba). +- ICE: oferta niesie jednego kandydata lokalnego (host); odpowiedź daje czterech kandydatów przekaźnika mediów Teams w 52.112.0.0/16 na porcie 3478 (dwóch UDP, dwóch `tcp-pass`), z którymi ICE łączy się wprost. Własnego przekaźnika TURN klient nie potrzebuje (alokacja na `default.relay.teams.microsoft.com` nie dała kandydatów, Live results 13, a połączenie i tak działało). +- DTLS: certyfikat procesora mediów to ECDSA P-384 z podpisem SHA-256; biblioteka `dtls` 0.13 (webrtc-rs 0.14) weryfikuje ECDSA SHA-256 tylko na P-256 i zrywa uzgadnianie, a usługa kończy nogę (410, `DTLSHandshakeFailed`). Z łatką, która wybiera krzywą z klucza certyfikatu, połączenie działa (Live results 15). +- Noga żyła 120 s bez przepływu dźwięku (usługa wysyłała tylko RTCP) i skończyła się `leave` sondy; `noMediaTimeoutMs` jej nie zakończył. Noga z samym kanałem danych, której klient zniknął bez `leave` (bez zamknięcia DTLS i SCTP i bez wyrejestrowania), trwała ponad 10 minut - użytkownik miał na liście uczestników dwa urządzenia, oba aktywne - i skończyła się dopiero `leave` wysłanym jako to urządzenie z innego procesu (`endpointId` i `participantId` nogi, HTTP 204; krok 0c, Live results 18). Czy wygasa sama (np. po TTL rejestracji, 900 s) albo dopiero z końcem spotkania - nie wiadomo. +- Widoczność: kod oficjalnego klienta pokazuje "You're in this meeting on another device" i "Transfer to this device" / "Add this device", gdy użytkownika nie ma w połączeniu na tym urządzeniu, a jest na innym (docs 10.5, tylko analiza statyczna); właściciel, będąc na spotkaniu w oficjalnym kliencie, nie widział tego komunikatu w żadnym przebiegu (Live results 18). Inni uczestnicy, zapytani przez właściciela, nie widzieli niczego - po przebiegach bez kanału danych (Live results 15) i po przebiegach z napisami na spotkaniu zespołu właściciela (krok 0c, Live results 18). Noga nigdy nie jest ukryta (Live results 17): dopóki jakiekolwiek urządzenie konta jest w połączeniu, konto jest na liście uczestników jako obecne - po wyjściu oficjalnego klienta noga sondy trzymała użytkownika na liście (`state: active`), a dołączenie bez użytkownika w spotkaniu przeszło (HTTP 200, bez poczekalni u organizatora) i pokazało go innym jako obecnego. - Język mówienia zmienia każdy uczestnik (poleceniem `setLanguage`), a każdy klient pokazuje zmianę wszystkim - open_mess nigdy go nie zmienia (4.4). ## 4. Narzędzia @@ -43,13 +58,13 @@ Trzy dodatkowe narzędzia modułu Teams (nazwy jak w etapie 2: `teams_`), ### 4.1 `teams_join_captions` (zapis) -`writes: true`, podlega prawu zapisu rozmowy spotkania (5.1): `never` = odmowa, `confirm` i `autonomous` = okno zgody. Dołączenie zawsze czeka na zgodę właściciela w oknie, także przy `autonomous`: wyjątek O1 obejmuje tylko sesje, które właściciel sam zatwierdza (recenzja commita 61091e3). Prawo czytania nie jest warunkiem zapisu (jak dla `send`; recenzja R3). Okno zgody według reguły z 5.1 (tytuł z pierwszego zdania opisu z manifestu, "Join the meeting for its live captions."), tytuł spotkania i obecni członkowie czatu spotkania z `describe-target` (informacyjnie, 5.1), a pod spodem opis z manifestu: open_mess dołącza do spotkania jako drugie urządzenie Twojego konta, bez dźwięku i obrazu, żeby czytać napisy; Twój Teams pokaże, że jesteś na spotkaniu na innym urządzeniu (O1). +`writes: true`, podlega prawu zapisu rozmowy spotkania (5.1): `never` = odmowa, `confirm` i `autonomous` = okno zgody. Dołączenie zawsze czeka na zgodę właściciela w oknie, także przy `autonomous`: wyjątek O1 obejmuje tylko sesje, które właściciel sam zatwierdza (recenzja commita 61091e3). Prawo czytania nie jest warunkiem zapisu (jak dla `send`; recenzja R3). Okno zgody według reguły z 5.1 (tytuł z pierwszego zdania opisu z manifestu, "Join the meeting for its live captions."), tytuł spotkania i obecni członkowie czatu spotkania z `describe-target` (informacyjnie, 5.1), a pod spodem opis z manifestu: open_mess dołącza do spotkania jako drugie urządzenie Twojego konta, bez dźwięku i obrazu, żeby czytać napisy; Teams będzie liczył drugie urządzenie Twojego konta na tym spotkaniu (O1). -Moduł (po zgodzie): sprawdza, że rozmowa to czat spotkania, które trwa (kalendarz albo stan połączenia z usługi rozmów; krok 0a ustala, skąd), że **użytkownik już jest na tym spotkaniu na innym urządzeniu** (oficjalnym kliencie) i że konto nie ma już aktywnej sesji napisów (najwyżej jedna na konto, inaczej błąd platformy "already reading the captions of another meeting; leave it first" - to konflikt stanu, nie złe argumenty; recenzja R11). Obecność użytkownika: przed dołączeniem z tego samego źródła, z którego oficjalny klient wie, że użytkownik jest na spotkaniu na innym urządzeniu (`checkForActiveCallOnDifferentEndpoints` -> `activeCallOnDifferentEndpointExists`, docs 10.5; krok 0a ustala, czy da się je odczytać bez dołączania), a gdy się nie da - zaraz po dołączeniu z listy uczestników: brak innego połączonego urządzenia użytkownika = natychmiastowe opuszczenie i błąd "join the meeting in Teams first" (recenzja R1). Gdy w trakcie sesji odchodzi ostatnie inne urządzenie użytkownika, sesja się kończy (4.3): open_mess nigdy nie jest na spotkaniu bez użytkownika. +Moduł (po zgodzie): czyta magazyn połączeń użytkownika (3, krok 3 droga (b)); magazyn może się spóźniać za świeżym dołączeniem (Live results 11), więc bez pasującej pozycji moduł pyta jeszcze do dwóch razy co 5 s, w budżecie wywołania (recenzja R15). Pozycja z `groupContext.threadId` równym rozmowie znaczy, że spotkanie trwa i **użytkownik już jest na nim na innym urządzeniu** (oficjalnym kliencie); jej `conversationController` to adres dołączenia, używany tylko, gdy ma schemat `https://` i host z listy hostów modułu (`api.flightproxy.teams.microsoft.com`) - inaczej akcja `failed` i żaden token tam nie idzie (recenzja R16). Brak takiej pozycji = akcja `failed` z "Teams does not list you in this meeting yet; join it in Teams first, or try again in a moment", zanim cokolwiek trafi do spotkania. Jeśli zaplanowane spotkanie nie ma pozycji z `groupContext` mimo obecności użytkownika (jak 2026-10-29, Live results 11; przyczyna nieznana), napisów z niego nie ma - świadome ograniczenie (recenzja commita 69eb728). Moduł sprawdza też, że ma wolne połączenie (najwyżej 4 sesje modułu naraz, 5.2; inaczej akcja `failed` z powodem `connection_limit`, zanim otworzy Trouter - recenzja R13) i że konto nie ma już aktywnej sesji napisów (najwyżej jedna na konto, inaczej błąd platformy "already reading the captions of another meeting; leave it first" - to konflikt stanu, nie złe argumenty; recenzja R11). Magazyn może się spóźniać za dołączeniem i nie mówi, które urządzenie jest w połączeniu (Live results 11), więc po dołączeniu drugim sprawdzeniem jest lista uczestników: brak innego połączonego urządzenia użytkownika = natychmiastowe opuszczenie i `reason: "user_not_in_meeting"` (recenzja R1). Gdy w trakcie sesji odchodzi ostatnie inne urządzenie użytkownika, sesja się kończy (4.3): open_mess nigdy nie jest na spotkaniu bez użytkownika - inaczej pokazywałby innym, że użytkownik wciąż jest na spotkaniu (Live results 17). Droga (a) z 3 (`epconv` z `meetingData`) potrzebuje linku spotkania, którego narzędzie nie dostaje, więc moduł jej nie używa; droga (b) daje kanał danych i napisy, także na zaplanowanym spotkaniu z kalendarza (krok 0c, Live results 18). -Granica wywołania (recenzja R2): samo wywołanie modułu, które wykonuje zatwierdzoną akcję, robi tylko kroki krótkie i synchroniczne - sprawdzenia wyżej, otwarcie połączenia Trouter (5.3), `rtc.open` i wysłanie `JoinConversation` z ofertą - i kończy się, gdy usługa rozmów przyjmie żądanie (HTTP 2xx), w budżecie wywołania (`CALL_TIMEOUT` 30 s, moduł planuje z 20 s jak dziś `api.rs`). Akcja jest wtedy `done`. Odpowiedź SDP, lista uczestników z botem i kanał danych przychodzą później jako zdarzenia (`on-ws-message` z kopert Trouter, `on-rtc-*`), które moduł obsługuje między wywołaniami; ich błędy kończą sesję (`state: "failed"` z powodem w `teams_read_captions`), nie akcję. Akcja jest `failed` tylko przy błędzie przed przyjęciem żądania; `uncertain` - gdy żądanie mogło dotrzeć, a odpowiedź nie (jak zapisy etapu 1), i wtedy moduł i tak wysyła `leave` (5.3). +Granica wywołania (recenzja R2, zmieniona po kroku 0b): moduł dostaje ramki Trouter tylko jako zdarzenia między wywołaniami, a adres zwrotny (`surl`) potrzebny w ciele dołączenia przychodzi dopiero w `trouter.connected`. Dlatego samo wywołanie modułu, które wykonuje zatwierdzoną akcję, robi tylko sprawdzenia wyżej (jeden `GET` magazynu połączeń), zapamiętuje sesję w stanie `joining` i otwiera połączenie Trouter (5.3), w budżecie wywołania (`CALL_TIMEOUT` 30 s, moduł planuje z 20 s jak dziś `api.rs`); akcja jest wtedy `done` ("dołączanie się zaczęło"). Resztę moduł robi w zdarzeniach: po `trouter.connected` w jednym wywołaniu zdarzenia rejestracja urządzenia, rekord sesji w `storage` (4.3), `rtc.open` i `POST` dołączenia z ofertą; po `/call/acceptance/` - `rtc.accept` i potwierdzenie. Akcja jest `failed` tylko przy błędzie sprawdzeń albo otwarcia Trouter, a `uncertain` (przekroczony limit wywołania, jak dla każdego zapisu w `pending.rs`) znaczy: dołączanie, które właściciel zatwierdził, mogło się zacząć - stan sesji pokazuje `teams_read_captions` (recenzje R13, R17). Żeby zatwierdzone wywołanie nie wykonało się dowolnie późno z kolejki modułu, aktor nie uruchamia żądania, na którego odpowiedź nikt już nie czeka (kanał odpowiedzi zamknięty, bo wywołujący przekroczył limit; `actor.rs`, dla wszystkich wywołań poza `end-sessions` i `sign-out`, które cofają to, co widzą inni, więc wykonują się zawsze - 4.3; GLM R24), a zaczęte wywołanie kończy się w swoim limicie 30 s; błędy po `done` kończą sesję (`state: "failed"` z powodem w `teams_read_captions`), nie akcję, a gdy `POST` dołączenia mógł dotrzeć bez odpowiedzi, moduł i tak wysyła `leave` (5.3). Usługa woła zwrotnie (`rosterUpdate`) jeszcze w trakcie `POST` dołączenia (Live results 15), a moduł odpowie na to dopiero po końcu wywołania - usługa na to nie czeka: gdy sonda odpowiadała na wywołania zwrotne dopiero po końcu `POST`, jak moduł, dołączenie trwało 0,35-1,4 s, a odpowiedź SDP i napisy przyszły po nim (krok 0c, Live results 18). -Po przyjęciu żądania: moduł przyjmuje odpowiedź SDP (`rtc.accept`), czeka na kanał danych - najwyżej 60 s od przyjęcia żądania, inaczej sesja `failed` (`reason: "media_failed"`) i `leave` (recenzja R3) - a potem tylko słucha (O3 = (b)): moduł nie dodaje bota napisów i nie wysyła mu żadnego polecenia (`start`, `setLanguage`, ...). Gdy w ciągu 30 s od otwarcia kanału danych bota nie ma w połączeniu albo jego tryb `ClosedCaptions` nie jest aktywny (nikt nie ma włączonych napisów), sesja kończy się `state: "failed"`, `reason: "no_captions"` ("turn on live captions in Teams first") i `leave`. Gdy ktoś wyłączy napisy w trakcie sesji, `teams_read_captions` daje `captions_on: false`; jeśli nie wrócą w 5 minut, sesja kończy się z `reason: "no_captions"`. Agent dostaje od narzędzia stan akcji zapisu (`waiting`, `done`, `failed`... jak dla `send`), a stan sesji (`joining`, `live`, przyczynę błędu) - z `teams_read_captions` (recenzja R1). Błędy (recenzja R4): `done` akcji znaczy tylko "usługa przyjęła żądanie dołączenia" - tak mówi opis narzędzia w manifeście. Spotkanie szyfrowane E2EE, poczekalnia (lobby), polityka wyłączająca napisy (`liveCaptionsEnabledType`, `allowTranscription`), brak włączonych napisów (O3) i odmowa usługi dają akcję `failed` z powodem, gdy moduł wie o nich przed przyjęciem żądania (z danych spotkania, polityki, odpowiedzi na `JoinConversation`); gdy wychodzą dopiero później (wywołanie zwrotne po 2xx), akcja zostaje `done`, a sesja kończy się `state: "failed"` z tym powodem w `teams_read_captions` i `leave`. Stan akcji nigdy nie jest zmieniany po fakcie. +Po otwarciu Trouter: moduł czeka na `trouter.connected`, wysyła dołączenie, przyjmuje odpowiedź SDP (`rtc.accept`) i czeka na kanał danych - wszystko razem najwyżej 60 s od otwarcia Trouter w wywołaniu akcji (cisza Trouter po uzgodnieniu WebSocket też się w tym mieści, recenzja R12); przekroczenie przed przyjęciem odpowiedzi SDP (brak `trouter.connected`, błąd rejestracji albo dołączenia) daje `reason: "protocol"`, a po niej (brak kanału danych) - `reason: "media_failed"` (recenzja R15), inaczej sesja `failed` (`reason: "media_failed"`) i `leave` (recenzja R3) - a potem tylko słucha (O3 = (b)): moduł nie dodaje bota napisów i nie wysyła mu żadnego polecenia (`start`, `setLanguage`, ...). Gdy w ciągu 30 s od otwarcia kanału danych bota nie ma w połączeniu albo jego tryb `ClosedCaptions` nie jest aktywny (nikt nie ma włączonych napisów), sesja kończy się `state: "failed"`, `reason: "no_captions"` ("turn on live captions in Teams first") i `leave`. Gdy ktoś wyłączy napisy w trakcie sesji, `teams_read_captions` daje `captions_on: false`; jeśli nie wrócą w 5 minut, sesja kończy się z `reason: "no_captions"`. Agent dostaje od narzędzia stan akcji zapisu (`waiting`, `done`, `failed`... jak dla `send`), a stan sesji (`joining`, `live`, przyczynę błędu) - z `teams_read_captions` (recenzja R1). Błędy (recenzja R4): `done` akcji znaczy tylko "dołączanie się zaczęło" - tak mówi opis narzędzia w manifeście. Spotkanie szyfrowane E2EE, poczekalnia (lobby), polityka wyłączająca napisy (`liveCaptionsEnabledType`, `allowTranscription`), brak włączonych napisów (O3) i odmowa usługi dają akcję `failed` z powodem, gdy moduł wie o nich w wywołaniu akcji (z magazynu połączeń, danych spotkania, polityki); gdy wychodzą dopiero później (odpowiedź na dołączenie, wywołania zwrotne), akcja zostaje `done`, a sesja kończy się `state: "failed"` z tym powodem w `teams_read_captions` i `leave`. Stan akcji nigdy nie jest zmieniany po fakcie. ### 4.2 `teams_read_captions` (odczyt) @@ -60,36 +75,49 @@ Po przyjęciu żądania: moduł przyjmuje odpowiedź SDP (`rtc.accept`), czeka n ``` - `state` z ustalonego zbioru: `not_joined`, `joining`, `live`, `ended` (spotkanie się skończyło albo sesja wygasła - fragmenty są jeszcze do odczytu przez 15 min), `failed`; `reason` przy `ended` i `failed` z ustalonego zbioru (`meeting_ended`, `update`, `user_left`, `user_not_in_meeting`, `idle`, `time_limit`, `left`, `refused`, `lobby`, `e2ee`, `policy`, `no_captions`, `media_failed`, `proxy`, `connection_limit`, `protocol`), inaczej `null`. - Sesja należy do pary (konto, rozmowa) (recenzja R8). `teams_read_captions` i `teams_leave_captions` z rozmową inną niż rozmowa sesji konta dają `state: "not_joined"` (odczyt, bez fragmentów, żetonu ani języka) i nic nie zmieniają (opuszczenie), nawet gdy konto ma sesję na innym spotkaniu - prawo czytania jednej rozmowy nigdy nie daje dostępu do napisów innej. -- `session`: żeton sesji (losowy identyfikator nadany przy dołączeniu, base64url). Każde dołączenie zaczyna nową sesję z pustym buforem i numeracją od 0, a bufor poprzedniej sesji tej rozmowy znika od razu. `cursor` z innym `session` niż bieżący = `invalid-args` "the captions session changed, read again without cursor" (recenzja R2), więc fragmenty dwóch sesji nigdy się nie mieszają. +- `session`: żeton sesji (losowy identyfikator nadany przy dołączeniu, base64url). Każde dołączenie zaczyna nową sesję z pustym buforem i numeracją od 0, a bufor poprzedniej sesji tej rozmowy znika od razu. `session` (z `cursor` albo bez) inny niż bieżący = `invalid-args` "the captions session changed, read again without session and cursor" (recenzje R2, R16), więc fragmenty dwóch sesji nigdy się nie mieszają; bez `session` odczyt dotyczy bieżącej sesji od początku. - `captions_on`: czy bot napisów jest w połączeniu z aktywnym trybem `ClosedCaptions` (ktoś ma włączone napisy), `null` przed pierwszą listą uczestników (O3). - `missed`: ile wiadomości kanału danych z napisami przepadło od początku sesji (pełna kolejka hosta, 5.2, albo bufor modułu); agent wie wtedy, że w napisach jest luka (recenzja R2). -- Tylko fragmenty ostateczne (`isFinal`), w kolejności przyjścia; `at` to czas fragmentu przepisany do RFC 3339 UTC (inaczej `null`), `speaker` to `displayName` z wiadomości (najwyżej 200 znaków), `text` najwyżej 4000 znaków (obcięte ze znacznikiem). MRI mówcy nie jest zwracane (minimalizacja; agent ma tylko nazwę, jak w czacie). -- `language` = `spokenLanguage`, tylko gdy pasuje do wzoru BCP 47 z etapu 2 (4.2 krok 6), inaczej `null`. -- Strona jak w etapie 2: suma fragmentów zserializowanych do JSON najwyżej `max_bytes`, najwyżej 2000 fragmentów, co najmniej jeden; `next_cursor` to numer następnego fragmentu (także gdy dziś więcej nie ma - agent pyta znowu z nim). -- Każde wywołanie przesuwa licznik bezczynności (4.3). +- Tylko fragmenty ostateczne (`isFinal`), w kolejności przyjścia; `at` to czas przyjścia fragmentu do modułu (zegar systemu, RFC 3339 UTC; opóźnienie napisów to sekundy) - `timestampAudioSent` nie jest czasem bezwzględnym (Live results 17), więc nie jest używany (recenzja R12, zmienione po recenzji commita 3b2c742), `speaker` to `displayName` z wiadomości (najwyżej 200 znaków), `text` najwyżej 4000 znaków (obcięte ze znacznikiem). MRI mówcy nie jest zwracane (minimalizacja; agent ma tylko nazwę, jak w czacie). +- `language` = `spokenLanguage` ostatniego fragmentu na stronie (języki fragmentów mogą się różnić, gdy ktoś zmieni język mówienia; recenzja R14), tylko gdy pasuje do wzoru BCP 47 z etapu 2 (4.2 krok 6), inaczej `null`; `null` także przy pustej stronie. +- Strona jak w etapie 2: suma fragmentów zserializowanych do JSON najwyżej `max_bytes`, najwyżej 2000 fragmentów, co najmniej jeden - także gdy ten jeden sam przekracza `max_bytes` (recenzja R16); `next_cursor` to numer następnego fragmentu (także gdy dziś więcej nie ma - agent pyta znowu z nim). +- Każde udane wywołanie przesuwa licznik bezczynności (4.3); błąd go nie przesuwa (recenzja R16). ### 4.3 `teams_leave_captions` i koniec sesji `writes: false` (recenzja R4): narzędzie tylko cofa obecność open_mess, nic nie dodaje ani nie pokazuje innym, więc nie potrzebuje prawa zapisu ani zgody - inaczej `write = never` ustawione w trakcie sesji blokowałoby wyjście. Podlega prawu czytania rozmowy jak `teams_read_captions`; bez niego sesja i tak kończy się sama po 10 minutach bez odczytu. Opuszcza spotkanie (tylko urządzenie open_mess) i zamyka połączenie mediów i Trouter. Sesja kończy się też sama, bez narzędzia: koniec spotkania (usługa rozmów albo bot), odejście ostatniego innego urządzenia użytkownika (4.1), 10 minut bez udanego `teams_read_captions` (agent przestał czytać albo stracił prawo czytania), 3 godziny od dołączenia, wylogowanie konta, wyłączenie, podmiana albo awaria modułu, zatrzymanie demona. Po końcu sesji fragmenty są w pamięci jeszcze 15 minut, potem znikają. Opuszczenie zawsze dociera do usługi rozmów (recenzja R2): -- Przy końcu sesji z woli modułu (narzędzie, timery, wylogowanie) moduł wysyła `leave` sam. -- Przy zatrzymaniu demona, wyłączeniu i podmianie modułu, i na początku aktualizacji: nowy eksport `module.end-sessions(account)` w `wit/module.wit`, wołany dla każdego konta modułu osobno i wykonywany jako to konto (`Caller::Account`), więc `leave` dostaje token WAM także przy kilku kontach Windows (recenzja R9) (dziś demon zatrzymuje moduły bez żadnego wywołania) - "zakończ teraz wszystko, co widzą inni (sesje napisów); instancja może dalej dostawać wywołania". Aktualizacja (recenzja R8): faza A (`update/quiesce.rs`) wstrzymuje zdarzenia modułów na co najmniej 60 s (do 30 min), co zatrzymałoby napisy i timery sesji, więc przed wstrzymaniem zdarzeń rdzeń woła `end-sessions` dla każdego konta każdego modułu; sesje kończą się z `reason: "update"` i `leave`, a po aktualizacji (albo jej anulowaniu) agent dołącza od nowa. Kolejność w fazie A (recenzje R9, R11): (1) rdzeń włącza blokadę zapisów modułów - od tej chwili do końca albo anulowania aktualizacji narzędzia piszące modułów są odrzucane ("update in progress, try again shortly", jak zapisy przy `WritesBlocked`), a `pending.rs` `run` sprawdza blokadę tuż przed `handle.call` (po ponownym opisie celu) i zatwierdzoną wtedy akcję `ModuleTool` kończy `failed` z tym powodem bez wywołania modułu; (2) rdzeń czeka, aż skończą się akcje `ModuleTool` już uruchomione (stan `approved`, liczone jak dziś zatwierdzone zapisy w `update/quiesce.rs`); (3) dopiero wtedy woła `end-sessions`; (4) potem wstrzymuje zdarzenia. Dołączenie nie może więc wejść po `end-sessions`. Rdzeń woła `end-sessions` przed zwolnieniem instancji raz dla każdego konta modułu, każde z limitem 5 s (idzie tą samą kolejką co wywołania, więc czeka też na trwające wywołanie - wyjście demona może się opóźnić do ok. 30 s + 5 s na każde konto z sesją, przy 4 kontach ok. 50 s; recenzje R5, R10); moduł wysyła w nim `leave` dla każdej sesji. Ścieżki (recenzja R4): zatrzymanie demona w `crates/open-mess/src/daemon.rs` woła `end-sessions` dla każdego konta każdego uchwytu z `Modules::handles()` przed końcem; `Modules::adopt` (synchroniczne, `modules.rs`) nie upuszcza już uchwytów modułów, które zatrzymuje lub podmienia, tylko je zwraca, a `Accounts::rescan_modules` (asynchroniczne, `accounts.rs`) woła na nich `end-sessions` dla każdego konta modułu i dopiero potem je upuszcza. Ponowny skan ma tę samą kolejność co faza A aktualizacji (recenzja commita add1643): blokada zapisów modułów przed `adopt`, czekanie na uruchomione akcje `ModuleTool`, `adopt`, `end-sessions` na zwróconych uchwytach, ich upuszczenie, zdjęcie blokady - nowa wersja modułu nie przyjmie więc dołączenia, zanim stara zakończy swoje sesje, i jedno konto nigdy nie ma dwóch sesji naraz. Skan jest rzadki i robi go użytkownik w oknie, więc krótka blokada (sekundy) nie przeszkadza. Skany idą po kolei (recenzja commita 0b2b0ff): `rescan_modules` trzyma asynchroniczny zamek `Accounts` przez cały przebieg, od założenia blokady do jej zdjęcia, więc drugi skan (np. drugie kliknięcie "Odśwież folder modułów" w trakcie `end-sessions`) czeka na koniec pierwszego i nie zdejmie jego blokady. -- Przy awarii modułu albo przerwanym `end-sessions` (zabity proces, przekroczony limit): nowa instancja powstaje dziś dopiero przy następnym wywołaniu (`actor.rs`), więc rdzeń, gdy host zgłosi awarię modułu (`ModuleHandle::state_changes`, stan `Crashed`), od razu woła `restore` dla kont tego modułu - to tworzy nową instancję (recenzja R3). Moduł przed `JoinConversation` zapisuje w `storage` minimalny rekord sesji (konto, adres rozmowy z usługi, identyfikator nogi uczestnika - bez treści i bez tokenów), a usuwa go po `leave`. Nowa instancja modułu (po przeładowaniu po awarii albo przy `restore` konta po starcie demona) wysyła `leave` dla każdego pozostałego rekordu i go usuwa; host zamyka połączenia `rtc` padniętej instancji od razu. Gdy host zatrzyma moduł po kolejnych awariach (`Disabled`, także po nieudanych `restore`), żadna instancja nie wyśle `leave`: rekordy zostają w `storage`, a `leave` idzie przy następnym uruchomieniu modułu ("Uruchom ponownie" w oknie albo start demona); do tego czasu Teams wygasza nogę sam po zerwaniu mediów (czas z kroku 0b), a okno konfiguracji przy module zatrzymanym z rekordem sesji mówi, że urządzenie może być jeszcze chwilę widoczne - świadome ograniczenie (recenzja R4). Rekord starszy niż 4 h jest usuwany bez `leave` (sesja wygasła po stronie Teams). -- Do czasu tego `leave` Teams może pokazywać urządzenie open_mess do wygaśnięcia nogi po swojej stronie (czas sprawdza krok 0b). -- Usunięcie konta w trakcie sesji (`accounts.rs` `remove`): `sign-out` modułu wysyła `leave` (jedno ponowienie w limicie wywołania). Gdy się nie uda, rdzeń i tak usuwa konto i jego sekrety, więc ponowić nie ma czym - Teams wygasza nogę sam (czas z kroku 0b). To świadome ograniczenie; okno konfiguracji przy usuwaniu konta z aktywną sesją napisów mówi, że urządzenie może być jeszcze chwilę widoczne (recenzja R3). +- Przy końcu sesji z woli modułu (narzędzie, timery, wylogowanie) moduł wysyła `leave` sam. Sesja kończy się od razu. Każdy `leave` z rekordem sesji w `storage` (niżej) - z działającej sesji, z `end-sessions` i z nowej instancji po awarii albo przy `restore` - usuwa rekord dopiero po sukcesie albo 404 i 410 (nogi już nie ma); jedynym wyjątkiem jest rekord starszy niż 4 h (niżej). Nieudany (błąd sieci, przekroczony czas, 429 albo 5xx) zostawia rekord, a instancja, która dalej działa, ponawia go timerem konta co 30 s przez najwyżej 10 minut (Codex R21, R23); potem rekord zostaje dla następnego uruchomienia modułu, a okno konfiguracji mówi to samo co przy module zatrzymanym z rekordem sesji. +- Przy zatrzymaniu demona, wyłączeniu i podmianie modułu, i na początku aktualizacji: nowy eksport `module.end-sessions(account)` w `wit/module.wit`, wołany dla każdego konta modułu osobno i wykonywany jako to konto (`Caller::Account`), więc `leave` dostaje token WAM także przy kilku kontach Windows (recenzja R9) (dziś demon zatrzymuje moduły bez żadnego wywołania) - "zakończ teraz wszystko, co widzą inni (sesje napisów); instancja może dalej dostawać wywołania". Aktualizacja (recenzja R8): faza A (`update/quiesce.rs`) wstrzymuje zdarzenia modułów na co najmniej 60 s (do 30 min), co zatrzymałoby napisy i timery sesji, więc przed wstrzymaniem zdarzeń rdzeń woła `end-sessions` dla każdego konta każdego modułu; sesje kończą się z `reason: "update"` i `leave`, a po aktualizacji (albo jej anulowaniu) agent dołącza od nowa. Kolejność w fazie A (recenzje R9, R11): (1) rdzeń włącza blokadę zapisów modułów - od tej chwili do końca albo anulowania aktualizacji narzędzia piszące modułów są odrzucane ("the modules are being updated or reloaded; nothing was done - try again shortly", krok 2), a `pending.rs` `run` sprawdza blokadę tuż przed `handle.call` (po ponownym opisie celu) i zatwierdzoną wtedy akcję `ModuleTool` kończy `failed` z tym powodem bez wywołania modułu; (2) rdzeń czeka, aż skończą się akcje `ModuleTool` już uruchomione (stan `approved`, liczone jak dziś zatwierdzone zapisy w `update/quiesce.rs`); (3) dopiero wtedy woła `end-sessions`; (4) potem wstrzymuje zdarzenia. Dołączenie nie może więc wejść po `end-sessions`. Rdzeń woła `end-sessions` przed zwolnieniem instancji raz dla każdego konta modułu, każde z limitem 5 s (idzie tą samą kolejką co wywołania, więc czeka też na trwające wywołanie - wyjście demona może się opóźnić do ok. 30 s + 5 s na każde konto z sesją, przy 4 kontach ok. 50 s; recenzje R5, R10); moduł wysyła w nim `leave` dla każdej sesji. Ścieżki (recenzja R4): zatrzymanie demona w `crates/open-mess/src/daemon.rs` woła `end-sessions` dla każdego konta każdego uchwytu z `Modules::handles()` przed końcem; `Modules::adopt` (synchroniczne, `modules.rs`) nie upuszcza już uchwytów modułów, które zatrzymuje lub podmienia, tylko je zwraca, a `Accounts::rescan_modules` (asynchroniczne, `accounts.rs`) woła na nich `end-sessions` dla każdego konta modułu i dopiero potem je upuszcza. Ponowny skan ma tę samą kolejność co faza A aktualizacji (recenzja commita add1643): blokada zapisów modułów przed `adopt`, czekanie na uruchomione akcje `ModuleTool`, `adopt`, `end-sessions` na zwróconych uchwytach, ich upuszczenie, zdjęcie blokady - nowa wersja modułu nie przyjmie więc dołączenia, zanim stara zakończy swoje sesje, i jedno konto nigdy nie ma dwóch sesji naraz. Skan jest rzadki i robi go użytkownik w oknie, więc krótka blokada (sekundy) nie przeszkadza. Skany idą po kolei (recenzja commita 0b2b0ff): `rescan_modules` trzyma asynchroniczny zamek `Accounts` przez cały przebieg, od założenia blokady do jej zdjęcia, więc drugi skan (np. drugie kliknięcie "Odśwież folder modułów" w trakcie `end-sessions`) czeka na koniec pierwszego i nie zdejmie jego blokady. Blokada zapisów modułów jest licznikiem (recenzja R14): faza A aktualizacji i każdy skan zakładają własną i zdejmują tylko własną, a narzędzia piszące modułów działają dopiero, gdy licznik wróci do zera - skan, który skończy się w trakcie fazy A, nie otworzy więc zapisów przed jej końcem. +- Przy awarii modułu albo przerwanym `end-sessions` (zabity proces, przekroczony limit): nowa instancja powstaje dziś dopiero przy następnym wywołaniu (`actor.rs`), więc rdzeń, gdy host zgłosi awarię modułu (`ModuleHandle::state_changes`, stan `Crashed`), od razu woła `restore` dla kont tego modułu - to tworzy nową instancję (recenzja R3). Moduł przed `POST` dołączenia zapisuje w `storage` minimalny rekord sesji (konto, adres rozmowy z usługi, `endpointId` i `participantId` nogi - bez treści i bez tokenów), a usuwa go po `leave`; `leave` wysłany z nowego procesu jako to urządzenie kończy nogę (Live results 18). Nowa instancja modułu (po przeładowaniu po awarii albo przy `restore` konta po starcie demona) wysyła `leave` dla każdego pozostałego rekordu (usunięcie rekordu i ponawianie jak wyżej); host zamyka połączenia `rtc` padniętej instancji od razu. Gdy host zatrzyma moduł po kolejnych awariach (`Disabled`, także po nieudanych `restore`), żadna instancja nie wyśle `leave`: rekordy zostają w `storage`, a `leave` idzie przy następnym uruchomieniu modułu ("Uruchom ponownie" w oknie albo start demona); do tego czasu noga może trwać do końca spotkania (krok 0c: ponad 10 minut bez mediów, Live results 18) i pokazywać użytkownika jako obecnego także po jego wyjściu, a okno konfiguracji przy module zatrzymanym z rekordem sesji mówi, że Teams może pokazywać użytkownika na spotkaniu do jego końca albo do ponownego uruchomienia modułu - świadome ograniczenie (recenzja R4, opis zmieniony po kroku 0c). Rekord starszy niż 4 h też dostaje `leave` (jedna próba, błąd usługi pomijany - noga mogła się już skończyć) i jest usuwany; nie wiadomo, czy noga bez `leave` w ogóle wygasa (Live results 18; recenzja commita 69eb728). +- Do czasu tego `leave` Teams pokazuje urządzenie open_mess, a noga sama nie wygasa w ciągu co najmniej 10 minut (krok 0c, Live results 18). +- Usunięcie konta w trakcie sesji (`accounts.rs` `remove`): `sign-out` modułu wysyła `leave` (jedno ponowienie w limicie wywołania). Gdy się nie uda, rdzeń i tak usuwa konto i jego sekrety, więc ponowić nie ma czym - noga może trwać do końca spotkania (krok 0c, Live results 18). To świadome ograniczenie; okno konfiguracji przy usuwaniu konta z aktywną sesją napisów mówi, że Teams może pokazywać użytkownika na spotkaniu do jego końca (recenzja R3, opis zmieniony po kroku 0c). ### 4.4 Czego moduł nigdy nie robi -Nie wysyła dźwięku ani obrazu i ich nie odbiera (host nie negocjuje takich strumieni, 5.2), nie włącza napisów, nie dodaje bota napisów i nie wysyła mu poleceń (O3), nie zmienia języka mówienia, języka napisów dla wszystkich ani ustawień spotkania, nie włącza transkrypcji ani nagrywania, nie pisze na czacie spotkania, nie zmienia stanu "przeczytane", nie dołącza do spotkań, na które użytkownik nie jest zaproszony, i nie dołącza bez wywołania `teams_join_captions` (żadnych wyzwalaczy w tym specu). +Nie wysyła dźwięku ani obrazu i ich nie odbiera (host nie ma ścieżki ani odbiornika mediów, 5.2), nie włącza napisów, nie dodaje bota napisów i nie wysyła mu poleceń (O3), nie zmienia języka mówienia, języka napisów dla wszystkich ani ustawień spotkania, nie włącza transkrypcji ani nagrywania, nie pisze na czacie spotkania, nie zmienia stanu "przeczytane", nie dołącza do spotkań, na które użytkownik nie jest zaproszony, i nie dołącza bez wywołania `teams_join_captions` (żadnych wyzwalaczy w tym specu). + +### 4.5 Czat spotkania + +Kontekst spotkania dla agenta to napisy i czat spotkania (decyzja właściciela z 2026-11-03, sekcja 2). open_mess nie dostaje do tego drugiego czytnika czatu: +- Rozmowa sesji napisów to wątek czatu spotkania (`conversation`, 4), więc `read_messages` z tym samym `conversation` i `account` czyta, co ludzie piszą na czacie: pod tym samym prawem czytania tej rozmowy (6), z nadawcą i treścią pod `untrusted` i bez zmiany stanu "przeczytane" (narzędzie wspólne etapu 1). Nowe wiadomości od ostatniego odczytu daje `since` (`sent_at` ostatniej przeczytanej wiadomości); `sent_at` jest z dokładnością do sekundy (`time.rs` `rfc3339`), więc taki odczyt może oddać tę wiadomość jeszcze raz (agent rozpozna ją po `id`), ale niczego nie gubi. Wynik ma najnowsze pierwsze, najwyżej `limit` (do 100) wiadomości; wynik z `limit` wiadomościami znaczy, że może ich być więcej, i dalsze daje `before` (najstarsza zwrócona wiadomość) z tym samym `since` - dopiero potem agent przesuwa `since`. Krótszy wynik musi więc być pełny, a dziś nie jest: odczyt, któremu skończy się budżet wywołania (`api::has_time`) albo `MAX_PAGES` stron przed `since` i `limit`, oddaje bez znacznika to, co zdążył przeczytać, także pustą listę po stronie samych wiadomości systemowych (`tools.rs` `messages`; Codex R22, R23). Krok 4 zmienia to tylko dla `read_messages`, jak `conversations` bez `partial`: taki odczyt kończy się błędem platformy ("Teams answers too slowly; try again later" po budżecie, "could not read that far back in one call" po `MAX_PAGES`) zamiast niepełnej listy. `mentions` i `writing_context` zostają, jak są (`mentions` czyta z założenia część wiadomości, jego opis to mówi). Gdy wiadomość z `before` zostanie w międzyczasie usunięta, `read_messages` daje `invalid-args` ("the message in before was not found in the conversation"), a agent czyta od tego samego `since` od nowa (GLM R24). Znany limit stronicowania `before` - rejestr rebutali (Codex R20). `read_messages` modułu Teams pyta usługę czatu przy każdym wywołaniu (`modules/teams/src/tools.rs`, `read_messages`), więc wiadomość jest widoczna od razu, nie dopiero po odpytywaniu co 60 s (`poll.rs`). +- Wiadomości systemowe czatu (początek i koniec połączenia, nagrywanie, zmiany składu) już dziś nie trafiają do wyniku: `chat.rs` `decode_messages` bierze tylko `Text` i `RichText/Html`. +- Edycja i usunięcie wiadomości nie zmieniają jej `sent_at`, więc odczyt z `since` późniejszym niż wiadomość ich nie pokaże (Codex R21); aktualny tekst daje ponowny odczyt od początku spotkania (`since` = początek terminu albo chwila dołączenia), w którym wiadomość ma `edited: true`, a usunięta znika. Opis `teams_read_captions` mówi to agentowi. +- Agent wie o tym z opisów narzędzi w manifeście Teams (zatwierdzanych przez użytkownika, spec 8.3): opis `teams_read_captions` kończy się zdaniem "What people write in the meeting chat is part of the meeting too: read_messages on the same conversation reads it, with since this meeting's start from list_meetings or the time you joined (a recurring meeting keeps one chat for all its occurrences); while a read returns limit messages, go on with before (the oldest message returned) and the same since. An edited message keeps its time, so read from that start again for the final text.", a opis `get_transcript` (etap 2) zdaniami "What people wrote in the meeting chat is part of the meeting too: read_messages on the same conversation reads it, with since a little before this occurrence's start from list_meetings, and on with before (the oldest message returned) while a read returns limit messages. A recurring meeting keeps one chat for all its occurrences, so keep the messages up to around this occurrence's end.". Opis `list_meetings` już mówi, że `read_messages` czyta czat spotkania, i daje `start` i `end` każdego terminu (`meetings.rs` `meeting_item`). +- Spotkanie cykliczne ma jeden wątek czatu dla wszystkich terminów (Codex R20), więc bez granic czasu agent przypisałby czat innego terminu do tego spotkania. Granice to `since` (początek terminu albo chwila dołączenia) i, dla transkrypcji minionego terminu, jego koniec, które agent bierze z `list_meetings`; ludzie piszą czasem trochę przed i po spotkaniu, więc to wskazówka w opisie, nie granica narzucana przez open_mess. Obie zmiany opisów wchodzą w tę samą zmianę manifestu co narzędzia z 4 (krok 4), więc użytkownik zatwierdza manifest raz. +- Napisy i czat da się ułożyć na jednej osi czasu: `at` fragmentu (czas przyjścia do modułu, zegar systemu, 4.2) i `sent_at` wiadomości (czas usługi czatu) są w UTC w RFC 3339; różnią się o opóźnienie napisów i różnicę zegarów, czyli o sekundy. +- Moduł nie używa do tego powiadomień czatu z Trouter (`/messaging`, 5.3): przychodzą dla wszystkich rozmów konta i dalej są pomijane bez czytania treści (11). +- Pisanie na czacie spotkania to `send` pod prawem zapisu tej rozmowy, poza tym specem; sesja napisów niczego na czacie nie pisze (4.4). +- Odrzucony wariant: `teams_read_captions` zwraca też nowe wiadomości czatu od kursora. Byłby to drugi czytnik tego samego wątku z drugim kursorem, powielający `read_messages` (to samo prawo, ten sam `untrusted`), a moduł i tak wykonuje wywołania po kolei, więc jedno wywołanie z dwoma odczytami nie daje niczego, czego nie dają dwa. ## 5. Zmiany w rdzeniu, hoście i module ### 5.1 Narzędzia piszące modułów (`crates/open-mess/src/tools/`, `pending.rs`, `approval.rs`) - `extra.rs`: narzędzie `writes: true` jest wystawiane, jeśli jego schemat ma wymaganą właściwość `conversation` (cel zapisu); reszta reguł z 5.1 etapu 2 bez zmian. Bez `conversation` w `required` - nie wystawiane ("a module write tool must name its conversation"). -- `pending.rs`: nowy rodzaj akcji `Kind::ModuleTool` z pełną nazwą narzędzia w wierszu (kolumna `kind` = `module:`; bez migracji schematu: `kind` jest tekstem). `create` jak dla zapisów wspólnych, z jedną różnicą: `autonomous` działa jak `confirm` - akcja narzędzia modułu zawsze czeka na zgodę w oknie (recenzja commita 61091e3); prawo zapisu rozmowy z `conversation`, przy `confirm` `describe-target(conversation, none)` i odcisk, przed wykonaniem ponowny opis i porównanie. Odcisk akcji narzędzia modułu to rozmowa i jej tytuł, nie uczestnicy (recenzja R6): celem dołączenia jest spotkanie, a jego skład zmienia się w trakcie - ludzie dochodzą przez link, a czat spotkania rośnie, więc odcisk z członków czatu unieważniałby zgody w pętli na początku spotkania, a i tak nie opisuje, czyje wypowiedzi będą w napisach. Uczestnicy są w oknie zgody tylko informacyjnie (5.1 `approval.rs`). Narzędzie piszące modułu, któremu skład rozmowy jest celem (np. wysyłka), wymaga osobnego specu. `run` woła moduł nazwą narzędzia bez przedrostka i z argumentami bez `account`, tylko jeśli moduł konta nadal deklaruje to narzędzie identycznie (jak strażnik z 4567b6e); inaczej `failed` ("the module's tool changed"). Wynik modułu nie jest pokazywany ani zapisywany (jak dziś). -- `approval.rs`: `Prompt` dla `ModuleTool` - tytuł "open_mess: approve ", rozmowa jak dla wysyłki, obecni członkowie czatu spotkania z dopiskiem "teraz w czacie spotkania; napisy obejmą każdego, kto będzie mówił, także osoby, które dołączą później" (recenzja R6), treść = pełny opis narzędzia z manifestu (zatwierdzonego przez użytkownika, spec 8.3) i wszystkie argumenty poza `conversation` jako `klucz: wartość`, w całości. Pokazywanie jak dla wysyłki dziś: toast, a gdy całość się nie mieści (np. spotkanie z kilkudziesięcioma uczestnikami) - "Show all", okno dialogowe albo okno konfiguracji, które pokazuje wszystko i jest miejscem zatwierdzania długich treści (`ui/commands.rs` `waiting`). Argumenty mieszczą się w 16 KiB (`extra.rs` `MAX_ARGS`), więc okno konfiguracji pokazuje je zawsze w całości - użytkownik nigdy nie zatwierdza czegoś, czego nie widział (spec 8.6; recenzje R1, R2, R6). Długa lista uczestników nigdy nie blokuje zgody. Narzędzie piszące z 4 (`teams_join_captions`) nie ma innych argumentów niż `conversation` i `account`. +- `pending.rs`: nowy rodzaj akcji `Kind::ModuleTool` z pełną nazwą narzędzia w wierszu (kolumna `kind` = `module:`; bez migracji schematu: `kind` jest tekstem). `create` jak dla zapisów wspólnych, z jedną różnicą: `autonomous` działa jak `confirm` - akcja narzędzia modułu zawsze czeka na zgodę w oknie (recenzja commita 61091e3); prawo zapisu rozmowy z `conversation`, przy `confirm` `describe-target(conversation, none)` i odcisk, przed wykonaniem ponowny opis i porównanie oraz ponowne sprawdzenie prawa zapisu rozmowy - `never` ustawione po utworzeniu akcji daje `voided` bez wywołania modułu (recenzja R15). Odcisk akcji narzędzia modułu to rozmowa i jej tytuł, nie uczestnicy (recenzja R6): celem dołączenia jest spotkanie, a jego skład zmienia się w trakcie - ludzie dochodzą przez link, a czat spotkania rośnie, więc odcisk z członków czatu unieważniałby zgody w pętli na początku spotkania, a i tak nie opisuje, czyje wypowiedzi będą w napisach. Uczestnicy są w oknie zgody tylko informacyjnie (5.1 `approval.rs`). Narzędzie piszące modułu, któremu skład rozmowy jest celem (np. wysyłka), wymaga osobnego specu. `run` woła moduł nazwą narzędzia bez przedrostka i z argumentami bez `account`, tylko jeśli moduł konta nadal deklaruje to narzędzie identycznie (jak strażnik z 4567b6e); inaczej `failed` ("the module's tool changed"). Wynik modułu nie jest pokazywany ani zapisywany (jak dziś). +- `approval.rs`: `Prompt` dla `ModuleTool` - tytuł "open_mess: approve ", rozmowa jak dla wysyłki, obecni członkowie czatu spotkania z ogólnym dopiskiem "Zgoda dotyczy rozmowy, nie tylko osób, które są w niej teraz." (rdzeń nie zna napisów; o napisach mówi opis narzędzia z manifestu; recenzja R6, krok 2), treść = pełny opis narzędzia z manifestu (zatwierdzonego przez użytkownika, spec 8.3) i wszystkie argumenty poza `conversation` jako `klucz: wartość`, w całości. Pokazywanie jak dla wysyłki dziś: toast, a gdy całość się nie mieści (np. spotkanie z kilkudziesięcioma uczestnikami) - "Show all", okno dialogowe albo okno konfiguracji, które pokazuje wszystko i jest miejscem zatwierdzania długich treści (`ui/commands.rs` `waiting`). Argumenty mieszczą się w 16 KiB (`extra.rs` `MAX_ARGS`), więc okno konfiguracji pokazuje je zawsze w całości - użytkownik nigdy nie zatwierdza czegoś, czego nie widział (spec 8.6; recenzje R1, R2, R6). Długa lista uczestników nigdy nie blokuje zgody. Narzędzie piszące z 4 (`teams_join_captions`) nie ma innych argumentów niż `conversation` i `account`. - `tools/mod.rs`: `Gate` (`WritesBlocked` i faza A aktualizacji od `end-sessions`, 4.3) traktuje narzędzia piszące modułów jak zapisy - rozpoznaje je po `writes` deklaracji z `extra::find`, nie po statycznej liście `Kind::for_tool` (recenzja R9); rdzeń publikuje w ich schemacie `account` jako wymagane; `call_extra` kieruje narzędzie piszące do `pending.create` (jak dziś `send`) po sprawdzeniu argumentów i konta (należy do modułu narzędzia), ale bez sprawdzania prawa czytania i bez kontroli po wywołaniu - zapis podlega prawu zapisu, jak `send` (recenzja R3); nigdy wprost do modułu. - Narzędzie `pending` pokazuje takie akcje z `kind` = pełna nazwa narzędzia (`teams_join_captions`), bez przedrostka `module:`, który jest tylko w kolumnie bazy (recenzja R6). @@ -98,106 +126,109 @@ Nie wysyła dźwięku ani obrazu i ich nie odbiera (host nie negocjuje takich st ```wit interface rtc { use http.{http-error}; - record relay { url: string, username: string, credential: string } - // A peer connection with one data channel and nothing else, relayed only through `relays`. - // Returns the connection and the host's SDP offer. - // The offer already holds the relay candidates: the host allocates on the relays first, - // within a few seconds (no trickle on the offer side). - open: func(relays: list, label: string) -> result, http-error>; + // A peer connection with one data channel and nothing else (5.2). Returns the connection and the host's offer: standard SDP, local + // candidates only, gathered fully. + open: func(label: string) -> result, http-error>; accept: func(conn: u32, answer: string) -> result<_, http-error>; - // A remote ICE candidate that came after the answer (trickle ICE). - add-candidate: func(conn: u32, candidate: string) -> result<_, http-error>; - send: func(conn: u32, data: list) -> result<_, http-error>; close: func(conn: u32); } ``` -i w `events`: `on-rtc-data(conn: u32, messages: list>, dropped: u32)`, `on-rtc-state(conn: u32, state: string)`; w `module`: `end-sessions: func(account: string)` (4.3). -- Host buduje ofertę sam: jedna sekcja `m=application` (SCTP, kanał `label`); żadnej sekcji audio ani wideo, chyba że krok 0b pokaże, że usługa ich wymaga - wtedy host dodaje je jako `a=inactive` bez kodeków dźwięku i obrazu, których nie ma (żadnego przechwytu, dekodowania ani wysyłania mediów). Odpowiedź z sekcjami, których oferta nie miała, albo z mediami aktywnymi = odmowa (`accept` zwraca błąd). -- Proxy (recenzja R3, poprawka R5): WebRTC nie przechodzi przez proxy HTTP. `net.rs` używa proxy systemowego, a `ws.rs` tuneluje przez proxy `http://` (CONNECT) i odmawia innych - żaden import nie omija proxy. `rtc` nie umie tunelować, więc też go nie omija: łączy się z przekaźnikiem tylko wtedy, gdy systemowe ustawienia proxy dają dla jego hosta połączenie bezpośrednie; gdy dla hosta przekaźnika działa proxy, `open` zwraca błąd i sesja kończy się `reason: "proxy"` ("your network sends traffic through a proxy; live captions need a direct connection"). W sieciach z wymuszonym proxy napisów więc nie ma - świadome ograniczenie (TURN przez TLS w tunelu CONNECT to osobna zmiana, gdy będzie potrzebna). Przekaźniki: UDP, TCP albo TLS (`turn:` i `turns:`) - co poda usługa Teams. -- ICE tylko przez przekaźniki (polityka `relay`): host łączy się wyłącznie z serwerami TURN z `relays`, których host jest na liście hostów modułu i przechodzi politykę adresów (`ip_policy.rs`, bez zakresów prywatnych); kandydaci z odpowiedzi serwera nie dostają bezpośredniego ruchu. Dane uwierzytelniające TURN przychodzą od modułu (z usługi Teams, krok 0a) i nie trafiają do logów. -- Kandydaci przychodzą w odpowiedzi SDP albo osobno (trickle, `add-candidate`); krok 0a ustala, którą drogą usługa ich daje (recenzja R7). Obie drogi przechodzą tę samą kontrolę `media.peers` niżej. +i w `events`: `on-rtc-data(conn: u32, messages: list>, dropped: u32)`, `on-rtc-state(conn: u32, state: string)`; w `module`: `end-sessions: func(account: string)` (4.3). Bez `send` (na kanale nie trzeba niczego wysyłać, 3) i bez `add-candidate` (kandydaci przychodzą w odpowiedzi SDP, krok 0b) - zmiana po kroku 0b. +- Oferta jest stała, moduł jej nie wybiera: tylko kanał danych SCTP `label` (krok 0c: usługa go przyjmuje i daje napisy, Live results 18) - żadnej sekcji audio ani wideo. Host nie ma nadajnika ani odbiornika mediów: nie ma czego wysłać (zero pakietów RTP od hosta), a przychodzące RTP odrzuca bez dekodowania i nigdy nie oddaje modułowi - moduł dostaje tylko wiadomości kanału danych. Konwersja do MS-SDP i z powrotem (3) jest w module (`sdp.rs`, 5.3), bo to format Teams; gwarancję "bez dźwięku i obrazu" daje lokalny opis hosta, nie tekst SDP od modułu. +- `accept` przyjmuje odpowiedź w standardowym SDP (po konwersji w module) tylko z tą samą jedyną sekcją danych co oferta. Sekcja audio, wideo albo każda dodatkowa = odmowa (`accept` zwraca błąd). +- Proxy (recenzja R3, poprawka R5, zmienione w R18): WebRTC nie przechodzi przez proxy HTTP, a `rtc` nie umie tunelować, więc nie może omijać proxy, któremu system kieruje ruch. `net.rs` i `ws.rs` biorą dziś tylko statyczne ustawienia proxy (PAC i WPAD to ich znany limit, `ponytail` w `net.rs`, spec 8.3) - dla `rtc` to za mało, bo bezpośredni UDP wbrew regule PAC ujawniłby sieci adres użytkownika. Dlatego `rtc` rozstrzyga proxy przez WinHTTP (`WinHttpGetProxyForUrl` z autowykrywaniem WPAD i skryptem `AutoConfigURL`, jak klient Windows; nowy `crates/open-mess-host/src/proxy.rs`, wiązanie Windows API z lokalnym `#[allow(unsafe_code)]`, wpisane do listy w AGENTS.md): `accept` pyta o każdy dozwolony adres drugiego końca (jak o `https://:/`); odpowiedź "bez proxy" = adres dozwolony; proxy dla adresu = adres odrzucony; autowykrywanie, które nie znalazło skryptu (domyślny Windows bez WPAD w sieci) = bez proxy; skrypt jest, ale nie da się go pobrać ani wykonać = adres odrzucony (fail closed). Po `accept` gniazdo połączenia przepuszcza tylko te sprawdzone pary adres i port kandydatów, więc adres albo port peer-reflexive z zakresu `media.peers`, o który nikt nie pytał proxy (skrypt PAC może rozróżniać porty), nie dostaje ruchu (recenzje commitów b70e9bb i 1451f19). Gdy nie zostanie żaden adres, `accept` zwraca błąd, a sesja kończy się `reason: "proxy"` ("your network sends traffic through a proxy; live captions need a direct connection"). W sieciach z wymuszonym proxy napisów więc nie ma - świadome ograniczenie. Przeniesienie `net.rs` i `ws.rs` na to samo rozstrzyganie to osobna zmiana (ich `ponytail`). +- ICE (zmienione po kroku 0b): bez przekaźnika po stronie hosta - żadnego serwera STUN ani TURN; kandydaci lokalni to tylko adresy komputera (jak u klienta webowego, który daje jednego kandydata host). Kandydaci odpowiedzi to przekaźniki mediów Teams (3), z którymi ICE łączy się wprost - UDP; do kandydatów `tcp-pass` po TCP tylko, jeśli biblioteka to umie (krok 1), inaczej sieć bez wyjścia UDP na port 3478 nie dostanie napisów (`reason: "media_failed"`) - świadome ograniczenie. - Konto tokenu w zdarzeniach (recenzje R7, R10): `wam-token` dostaje opcjonalny argument `account`. W wywołaniu dla konta może nazwać tylko to konto (inne = odmowa); w zdarzeniu (`Caller::Event`: gniazdo, `rtc`, timer) może nazwać dowolne konto modułu z zapisanym kontem Windows, i wtedy token jest dla niego. Bez `account` wszystko działa jak dziś. Moduł Teams nazywa w zdarzeniach konto sesji, której zdarzenie dotyczy; wspólny timer odpytywania (`component.rs`, jedno zdarzenie dla wszystkich kont) nazywa konto, które akurat odpytuje - zdarzenia nie są przypisywane do konta po tym, kto otworzył połączenie czy timer (to psułoby wspólny timer; recenzja R10). Przy kilku kontach Windows znika dzisiejsza odmowa "several Windows accounts" w zdarzeniach, które konto nazywają. -- Drugi koniec (recenzja R5): przekaźnik TURN przekazuje dane do adresu, który podaje odpowiedź SDP, a tę podaje moduł. Żeby moduł nie wysłał nimi danych pod dowolny adres, manifest wymienia zakresy adresów drugiego końca (`media.peers`: lista CIDR, np. zakresy mediów Teams, które Microsoft publikuje dla Microsoft 365; konkretne wartości potwierdza krok 0a z konfiguracji klienta i krok 0b z kandydatów w odpowiedzi). Host: kandydaci odpowiedzi spoza `media.peers` albo w zakresach prywatnych są odrzucani; uprawnienia TURN (`CreatePermission`, `ChannelBind`, RFC 8656) zakłada tylko dla adresów z `media.peers`; odpowiedź bez żadnego dozwolonego kandydata = `accept` zwraca błąd. Moduł nie wskaże więc żadnego adresu spoza listy hostów i `media.peers`, które zatwierdził użytkownik. -- Limity: najwyżej 4 połączenia na moduł (moduł Teams trzyma jedno na konto, 4.1; recenzja R1; piąte konto naraz dostaje `reason: "connection_limit"`, recenzja R9), wiadomość przychodząca najwyżej 64 KiB, najwyżej 256 KiB/s i 50 wiadomości/s na połączenie (nadmiar odrzucany; luka trafia do `missed` sesji, 4.2 - recenzja R11), wysyłanie najwyżej 16 KiB na wiadomość i 10 wiadomości/s. Host zamyka połączenie, gdy ICE lub DTLS zgłosi zerwanie (brak zgody ICE, `failed`), i po 4 h; cisza na kanale danych (nikt nie mówi) nie jest powodem - utrzymują go mechanizmy ICE i SCTP biblioteki (recenzja R1). Zdarzenia jak w `ws.rs`, z jedną różnicą (recenzja R2): moduł wykonuje wywołania po kolei, więc w trakcie wolnego wywołania (np. odpytywania czatu, do 30 s) wiadomości czekają w kolejce hosta per połączenie - najwyżej 4 MiB i 2000 wiadomości, czyli kilka minut napisów - i trafiają do modułu partiami: `on-rtc-data(conn: u32, messages: list>, dropped: u32)` niesie wszystkie zebrane wiadomości i liczbę odrzuconych od poprzedniego zdarzenia (pełna kolejka albo limity wyżej). Moduł sumuje `dropped` w `missed` sesji (4.2), więc luka nigdy nie jest cicha; przepełnienie nie zatrzymuje modułu. -- Biblioteka: WebRTC w Ruście z klientem TURN i kanałami danych (kandydat `webrtc` z webrtc-rs); wybór w kroku 1 po sprawdzeniu licencji (`deny.toml`), rozmiaru binarki i tego, że działa w jednym procesie z tokio demona (zasada "One process"). Żaden silnik przeglądarki. -- Manifest deklaruje użycie `rtc` (nowe pole `media: option` z `peers: list` (CIDR), wchodzi do skrótu manifestu, więc wymaga ponownej zgody; zakresy sprawdzane przy ładowaniu: poprawny CIDR, bez zakresów prywatnych, zakres nie szerszy niż /8 dla IPv4 i /16 dla IPv6, czyli długość prefiksu co najmniej 8 i 16 (`0.0.0.0/0` odrzucony; recenzja R6), najwyżej 32 zakresy); okno zgody mówi "Połączenie mediów (bez dźwięku i obrazu, poza proxy) przez przekaźniki z listy hostów modułu do adresów " - przekaźnikiem może być tylko host z listy, a listę okno pokazuje osobno (recenzja R10). Moduł bez `media` dostaje odmowę `open`. - +- Drugi koniec (recenzja R5, zmieniona po kroku 0b): adres drugiego końca podaje odpowiedź SDP, a tę podaje moduł. Żeby moduł nie wysłał nimi ruchu pod dowolny adres, manifest wymienia zakresy adresów drugiego końca (`media.peers`: lista CIDR; dla Teams zakresy mediów Teams z listy adresów, którą Microsoft publikuje dla Microsoft 365 - w kroku 0b wszyscy kandydaci byli w 52.112.0.0/16). Host: kandydaci odpowiedzi spoza `media.peers` albo w zakresach prywatnych (`ip_policy.rs`) są odrzucani, zanim ICE wyśle do nich cokolwiek; odpowiedź bez żadnego dozwolonego kandydata = `accept` zwraca błąd. Kandydaci peer-reflexive (z przychodzących sprawdzeń ICE) wymagają hasła ICE z oferty, które zna tylko usługa; host i tak przyjmuje je tylko z adresów w `media.peers` (krok 1 sprawdza, że biblioteka na to pozwala; jeśli nie - spec wraca do review). Moduł nie wskaże więc żadnego adresu spoza `media.peers`, które zatwierdził użytkownik. +- Limity: najwyżej 4 połączenia na moduł (moduł Teams trzyma jedno na konto, 4.1; recenzja R1; piąte konto naraz dostaje `reason: "connection_limit"`, recenzja R9), wiadomość przychodząca najwyżej 64 KiB, najwyżej 256 KiB/s i 50 wiadomości/s na połączenie (krok 0b: około 2,5 wiadomości/s; nadmiar odrzucany, luka trafia do `missed` sesji, 4.2 - recenzja R11). Host zamyka połączenie, gdy ICE lub DTLS zgłosi zerwanie (brak zgody ICE, `failed`), i po 4 h; cisza na kanale danych (nikt nie mówi) nie jest powodem - utrzymują go mechanizmy ICE i SCTP biblioteki (recenzja R1). Zdarzenia jak w `ws.rs`, z jedną różnicą (recenzja R2): moduł wykonuje wywołania po kolei, więc w trakcie wolnego wywołania (np. odpytywania czatu, do 30 s) wiadomości czekają w kolejce hosta per połączenie - najwyżej 4 MiB i 2000 wiadomości, czyli kilka minut napisów - i trafiają do modułu partiami: `on-rtc-data(conn: u32, messages: list>, dropped: u32)` niesie wszystkie zebrane wiadomości i liczbę odrzuconych od poprzedniego zdarzenia (pełna kolejka albo limity wyżej). Moduł sumuje `dropped` w `missed` sesji (4.2), więc luka nigdy nie jest cicha; przepełnienie nie zatrzymuje modułu. +- Biblioteka: webrtc-rs (`webrtc` 0.21, MIT/Apache-2.0) - działa w jednym procesie z tokio demona (zasada "One process"). Sonda z kroków 0b i 0c była na 0.14, która potrzebowała poprawki `dtls` dla certyfikatu ECDSA P-384 z podpisem SHA-256 (3); wydanie 0.21 (`rtc-dtls`) wybiera krzywą P-384 po długości klucza, więc łatki w repo nie ma (zmiana w kroku 1). Host podaje bibliotece własny runtime (trait `Runtime`): gniazdo UDP, które wysyła tylko do adresów z `media.peers` i odrzuca datagramy z innych (także peer-reflexive), bez TCP i bez rozwiązywania nazw. Krok 1 sprawdza połączenie z drugim hostem, którego klucz P-384 podpisuje SHA-256, bez `allow_insecure_verification_algorithm`; zgodność 0.21 z Teams sprawdza dopiero test na żywo (krok 5). Żaden silnik przeglądarki. +- Manifest deklaruje użycie `rtc` (nowe pole `media: option` z `peers: list` (CIDR), wchodzi do skrótu manifestu, więc wymaga ponownej zgody; zakresy sprawdzane przy ładowaniu: poprawny CIDR, bez zakresów prywatnych, zakres nie szerszy niż /8 dla IPv4 i /16 dla IPv6, czyli długość prefiksu co najmniej 8 i 16 (`0.0.0.0/0` odrzucony; recenzja R6), najwyżej 32 zakresy); okno zgody mówi "Połączenie mediów (bez dźwięku i obrazu, poza proxy) do adresów " (recenzja R10; po kroku 0b bez przekaźników z listy hostów). Moduł bez `media` dostaje odmowę `open`. ### 5.3 Moduł Teams -- Trouter (docs 5) przez całą sesję (do 3 h): alokacja, połączenie i rejestracja odnawiane przed wygaśnięciem tak jak w oficjalnym kliencie (`se` alokacji, TTL rejestracji 3600 s, ponowne łączenie z wykładniczym czekaniem); odnowienie, które się nie uda w 2 minuty, kończy sesję z `reason: "protocol"` i `leave` - napisy nigdy nie znikają w ciszy (recenzja R3). Połączenie WebSocket (`websocket` host), rejestracja z własnym identyfikatorem urządzenia open_mess (UUID z `api::uuid`, przechowywany w `storage`, nigdy identyfikator oficjalnego klienta), tylko na czas sesji napisów; adres Trouter jako `baseMessagingChannelUrl` dla adresów zwrotnych usługi rozmów. Kształt alokacji, rejestracji i kopert wiadomości ustala krok 0a, potwierdza 0b ("Needs live capture" 5, 7, 8). Jeśli krok 0a pokaże, że broker wystarczy bez Trouter, spec wraca do review. -- `captions.rs`: dołączenie (`JoinConversation` z ofertą z `rtc.open`, `audioDirection` Inactive, `videoDirection` Disabled, `datachannelDirection` Bidirectional), przyjęcie odpowiedzi SDP i zdarzeń połączenia z wywołań zwrotnych (lista uczestników z wpisem bota i jego trybem `ClosedCaptions`, koniec spotkania), bez żadnego polecenia do bota (O3), dekoder wiadomości kanału danych (ramkowanie identyfikatorów danych ustala krok 0a; tylko identyfikator 3; nieznany kształt = wiadomość pominięta i zliczona, a 100 kolejnych złych = sesja `failed`), bufor fragmentów w pamięci (najwyżej 20 000 fragmentów i 4 MiB na sesję, najstarsze wypadają; `cursor` sprzed najstarszego zachowanego = wynik od najstarszego zachowanego, a wypadłe, których agent nie przeczytał, liczą się do `missed`), timery końca sesji (4.3) - jeden timer hosta na konto (moduł trzyma najbliższy termin z własnej listy: bezczynność, 3 h, 60 s czekania na kanał, 30 s i 5 min czekania na włączone napisy, odnowienia Trouter), więc 4 sesje to 4 timery, daleko w limicie `MAX_TIMERS` = 32 (`limits.rs`; recenzja R7), opuszczenie (`leave` usługi rozmów, zamknięcie `rtc` i Trouter), rekord sesji w `storage` i `end-sessions` (4.3). -- `manifest.rs`: trzy narzędzia z 4, `media` z zakresami drugiego końca z kroku 0a, hosty usługi rozmów i przekaźników z kroku 0a (dosłownie albo wzorcem `*.` z regułą domeny rejestrowalnej), zakresy WAM, jeśli usługa rozmów albo przekaźniki wymagają nowego odbiorcy tokenu (krok 0a). Zmiana manifestu = ponowna zgoda. +- Trouter (docs 5; 3, kroki 1-2) tylko na czas sesji napisów (do 3 h): połączenie v4c z `user.authenticate` (token IC3, zakres czatu, który moduł już ma) i rejestracja urządzenia z własnym identyfikatorem open_mess (UUID z `api::uuid`, przechowywany w `storage`, nigdy identyfikator oficjalnego klienta), odnawiana przed wygaśnięciem (TTL 900 s) i usuwana na końcu sesji. Zerwane połączenie: ponowne łączenie (`reconnectUrl`, wykładnicze czekanie); odnowienie połączenia albo rejestracji, które się nie uda w 2 minuty, kończy sesję z `reason: "protocol"` i `leave` - napisy nigdy nie znikają w ciszy (recenzja R3). Na każde żądanie z Trouter moduł odpowiada w obsłudze zdarzenia (200, jak klient), a ramki Socket.IO obsługuje jak w docs 10.7 (heartbeat `2::` odsyłany, inaczej serwer zrywa połączenie po `pingTimeoutMs`; recenzja R17); powiadomienia czatu (`/messaging`) pomija bez czytania treści. `surl` z `trouter.connected` buduje adresy zwrotne usługi rozmów. Przerwa w Trouter (od zerwania do ponownego połączenia) albo zdarzenie `trouter.message_loss` (Live results 13) znaczy, że moduł mógł przegapić `rosterUpdate`, więc od tej chwili nie przyjmuje napisów (wiadomości kanału danych są odrzucane i liczone w `missed`, `captions_on: null`); po połączeniu czyta listę uczestników na nowo (`GET `, klucz `roster`, Live results 13) i wznawia odbiór tylko, gdy inne urządzenie użytkownika jest nadal połączone - inaczej `leave` i `reason: "user_left"`. Lista uczestników idzie przez HTTPS do usługi rozmów, nie przez Trouter, więc w przerwie Trouter moduł czyta ją tak samo co 10 s (timer konta) i bez innego urządzenia użytkownika kończy sesję od razu (`leave`, `reason: "user_left"`) - odejście użytkownika w trakcie przerwy nie zostawia go na spotkaniu do końca ponownego łączenia (do 2 minut; Codex R24). Adresy zwrotne usługi rozmów (`notificationLinks` i linki z ciała dołączenia) wskazują `surl` z chwili dołączenia; gdy po ponownym połączeniu `surl` jest inny, moduł ich nie przepina, tylko kończy sesję z `reason: "protocol"` i `leave` (agent może dołączyć od nowa; recenzja R14). W kroku 0c ponowne połączenie z tym samym identyfikatorem urządzenia i zapamiętanymi `connectparams` zachowało `surl`, zarówno przez `reconnectUrl`, jak i przez adres domyślny (po czystym zamknięciu i 3 s przerwy; Live results 18), więc to zabezpieczenie na wypadek innego zachowania usługi. `trouter.message_loss` przychodzi często (5 na przebieg w kroku 0c, przyczyna nieznana; Live results 18): bez zerwania połączenia moduł czyta listę uczestników od razu, w tym samym wywołaniu zdarzenia, więc wiadomości kanału danych czekające w kolejce hosta (5.2) nie przepadają. To samo, gdy odpowiedź na żądanie Trouter spóźni się przez długie wywołanie modułu (do 30 s) i usługa zgłosi błąd doręczenia: moduł czyta listę uczestników przy następnym zdarzeniu (recenzja R13). +- `captions.rs`: dołączenie krokami z 3, droga (b) (magazyn połączeń, `POST` dołączenia bez koperty `payload` z ofertą z `rtc.open`, potwierdzenie przyjęcia), przyjęcie odpowiedzi SDP i zdarzeń połączenia z wywołań zwrotnych (lista uczestników z wpisem bota i jego trybem `ClosedCaptions`, koniec spotkania), bez żadnego polecenia do bota (O3), dekoder wiadomości kanału danych (ramkowanie z docs 10.7, potwierdzone na żywo; tylko identyfikator 3; nieznany kształt = wiadomość pominięta i zliczona, a 100 kolejnych złych = sesja `failed`), bufor fragmentów w pamięci (najwyżej 20 000 fragmentów i 4 MiB na sesję, najstarsze wypadają; `cursor` sprzed najstarszego zachowanego = wynik od najstarszego zachowanego, a wypadłe, których agent nie przeczytał, liczą się do `missed`), timery końca sesji (4.3) - jeden timer hosta na konto (moduł trzyma najbliższy termin z własnej listy: bezczynność, 3 h, 60 s czekania na kanał, 30 s i 5 min czekania na włączone napisy, odnowienie rejestracji, lista uczestników w przerwie Trouter, ponowienie `leave`), więc 4 sesje to 4 timery, daleko w limicie `MAX_TIMERS` = 32 (`limits.rs`; recenzja R7), opuszczenie (`leave` usługi rozmów, zamknięcie `rtc` i Trouter), rekord sesji w `storage` i `end-sessions` (4.3). +- `sdp.rs` (nowy): konwersja oferty hosta do MS-SDP i odpowiedzi z powrotem (3), ścisła - nieznana albo za długa linia, za dużo sekcji = błąd, sesja `failed` z `reason: "protocol"`. +- `manifest.rs`: trzy narzędzia z 4, `media` z zakresami mediów Teams z listy adresów Microsoft 365 (krok 0b: kandydaci w 52.112.0.0/16), host usługi rozmów `api.flightproxy.teams.microsoft.com` (Trouter i rejestracja są już na liście); zakresy WAM bez zmian (Trouter używa tokenu IC3 czatu, usługa rozmów tokenu Skype). Zmiana manifestu = ponowna zgoda. - Wszystko z sieci jest wrogie: odpowiedzi usługi rozmów, koperty Trouter, wpisy listy uczestników (`commandUrl` bota nie jest używany - O3), SDP odpowiedzi, wiadomości kanału danych - limity rozmiarów i pętli, ścisłe dekodowanie, brak paniki. ## 6. Uprawnienia i przepływ treści - Dołączenie to zapis (5.1): prawo zapisu rozmowy spotkania, przy `confirm` zgoda w oknie poza zasięgiem agenta. Odczyt i opuszczenie (4.3) to prawo czytania tej rozmowy, sprawdzane przed i po wywołaniu (etap 2, 5.1). +- Czat spotkania (4.5) czyta `read_messages` tej samej rozmowy pod tym samym prawem czytania; w uprawnieniach nic się nie zmienia. - Treść napisów, nazwy mówców i język trafiają tylko do agentów podłączonych przez użytkownika (i dalej do dostawców ich modeli), pod `untrusted` z notką - jak wiadomości i transkrypcje (D2 etapu 2). -- Treść nie jest zapisywana na dysku: bufor fragmentów tylko w pamięci modułu, w logach tylko stany, kody i liczby (spec 8.4). Jedyny zapis w `storage` modułu to rekord sesji z 4.3 (konto, adres rozmowy, identyfikator nogi - bez treści i tokenów), usuwany po `leave` (recenzja R5). Restart modułu albo demona = sesja i bufor tracone. +- Treść nie jest zapisywana na dysku: bufor fragmentów tylko w pamięci modułu, w logach tylko stany, kody i liczby (spec 8.4). Jedyne zapisy w `storage` modułu to identyfikator urządzenia open_mess (5.3) i rekord sesji z 4.3 (konto, adres rozmowy, identyfikator nogi - bez treści i tokenów), usuwany po `leave` (recenzja R5). Restart modułu albo demona = sesja i bufor tracone. ## 7. Kroki -0a. **REA klienta desktopowego** (statycznie, jak docs 10): skąd `activeCallOnDifferentEndpointExists` (docs 10.5) wie, że użytkownik jest na spotkaniu na innym urządzeniu, i czy da się to odczytać bez dołączania (4.1), dokładne żądanie `JoinConversation` i `leave` (adres, nagłówki, ciało, rola i pola uczestnika), skąd klient bierze adres usługi rozmów (klucz ECS i żądanie konfiguracji, które moduł może wykonać sam), wywołania zwrotne (ścieżki, koperty Trouter albo broker), skąd przychodzą kandydaci ICE odpowiedzi (w SDP czy osobno), skąd pochodzą przekaźniki TURN i ich dane (usługa i odbiorca tokenu), ramkowanie kanału danych (jak identyfikator danych 3 jest zapisany w wiadomości), jak lista uczestników niesie tryb bota (`ClosedCaptions` aktywny lub nie), sygnał końca spotkania. Wynik do `docs/protocols/teams.md` jako 10.7, oznaczony jako statyczny. Jeśli cokolwiek z 5.2-5.3 okaże się inne (np. wymagane własne ramkowanie mediów zamiast SCTP), spec wraca do review. -0b. **Sprawdzenie na żywo przy właścicielu, za jego zgodą w czacie**, na testowym spotkaniu, które organizuje właściciel, z jego oficjalnym klientem w spotkaniu i z napisami włączonymi przez niego w oficjalnym kliencie; sondą poza repo (jak w etapie 2), tylko kody, klucze i liczby, żadnej treści napisów: czy usługa przyjmuje ofertę tylko z kanałem danych (albo z nieaktywnym dźwiękiem), czy przychodzą wiadomości z identyfikatorem 3 (liczba, klucze), czy połączenie trwa bez przepływu dźwięku (`noMediaTimeoutMs`, "Needs live capture" 17), co pokazuje oficjalny klient właściciela, i - jeśli właściciel poprosi o to współpracownika - co widzi inny uczestnik ("Needs live capture" 18). Wyniki do "Live results". Bramki: inny uczestnik widzi dodatkowe urządzenie albo powiadomienie = decyzja O2 przed krokiem 1; usługa odrzuca każdą ofertę bez aktywnego dźwięku = stop i raport (open_mess nie wyśle ani nie przyjmie dźwięku). -1. Host: import `rtc` z testami (pętla zwrotna z drugim hostem WebRTC w teście, oferta bez mediów, odrzucenie odpowiedzi z aktywnymi mediami, tylko TURN z listy hostów, limity). +0a. **REA klienta desktopowego** - zrobione 2026-10-29 (docs 10.7). Plan był taki, statycznie, jak docs 10: skąd `activeCallOnDifferentEndpointExists` (docs 10.5) wie, że użytkownik jest na spotkaniu na innym urządzeniu, i czy da się to odczytać bez dołączania (4.1), dokładne żądanie `JoinConversation` i `leave` (adres, nagłówki, ciało, rola i pola uczestnika), skąd klient bierze adres usługi rozmów (klucz ECS i żądanie konfiguracji, które moduł może wykonać sam), wywołania zwrotne (ścieżki, koperty Trouter albo broker), skąd przychodzą kandydaci ICE odpowiedzi (w SDP czy osobno), skąd pochodzą przekaźniki TURN i ich dane (usługa i odbiorca tokenu), ramkowanie kanału danych (jak identyfikator danych 3 jest zapisany w wiadomości), jak lista uczestników niesie tryb bota (`ClosedCaptions` aktywny lub nie), sygnał końca spotkania. Wynik do `docs/protocols/teams.md` jako 10.7, oznaczony jako statyczny. Jeśli cokolwiek z 5.2-5.3 okaże się inne (np. wymagane własne ramkowanie mediów zamiast SCTP), spec wraca do review. +0b. **Sprawdzenie na żywo** - zrobione 2026-10-30 (Live results 13-16, wnioski w 3): napisy przychodzą przy ofercie z audio `sendonly` bez ścieżki, noga żyje bez dźwięku, inni uczestnicy jednego spotkania nic nie widzieli (po przebiegach bez kanału danych); żadna bramka nie zadziałała (nic nie jest wysyłane ani odbierane). Plan był taki: **sprawdzenie przy właścicielu, za jego zgodą w czacie**, na testowym spotkaniu, które organizuje właściciel, z jego oficjalnym klientem w spotkaniu i z napisami włączonymi przez niego w oficjalnym kliencie; sondą poza repo (jak w etapie 2), tylko kody, klucze i liczby, żadnej treści napisów: czy usługa przyjmuje ofertę tylko z kanałem danych (albo z nieaktywnym dźwiękiem), czy przychodzą wiadomości z identyfikatorem 3 (liczba, klucze), czy połączenie trwa bez przepływu dźwięku (`noMediaTimeoutMs`, "Needs live capture" 17), co pokazuje oficjalny klient właściciela, i - jeśli właściciel poprosi o to współpracownika - co widzi inny uczestnik ("Needs live capture" 18). Wyniki do "Live results". Bramki: inny uczestnik widzi dodatkowe urządzenie albo powiadomienie = decyzja O2 przed krokiem 1; usługa odrzuca każdą ofertę bez aktywnego dźwięku = stop i raport (open_mess nie wyśle ani nie przyjmie dźwięku). +0c. **Powtórka na żywo przed krokiem 1** - zrobione 2026-11-02 (Live results 18, wnioski w 3, 4.1, 4.3 i 5.2): (1) sam kanał danych daje napisy - to jest oferta hosta; (2) audio `inactive`: ICE się nie połączyło (jedna próba, niepotrzebna po (1)); (3) droga (b) daje kanał danych i napisy; (4) zaplanowane spotkanie z kalendarza ma pozycję z `groupContext.threadId`; (5) usługa nie czeka na odpowiedzi na wywołania zwrotne; (6) noga bez `leave` trwała ponad 10 minut i skończyła się dopiero `leave` z innego procesu; (7) nikt nic nie widział, a właściciel nie widział komunikatu o drugim urządzeniu; (9) `surl` zostaje po ponownym połączeniu. Bramki nie zadziałały. Plan był taki (przy właścicielu, za jego zgodą w czacie, na spotkaniu z jego oficjalnym klientem i włączonymi napisami; sonda poza repo, tylko kody, klucze i liczby): (1) oferta z samym kanałem danych i (2) z audio `inactive`, obie z poprawną sekcją `x-data` - host używa pierwszej, która daje napisy, a `sendonly` bez ścieżki tylko, gdy obie zawiodą (5.2); (3) dołączenie drogą (b) z 3 (adres z magazynu połączeń, bez `epconv`) daje kanał danych i napisy; (4) na zaplanowanym spotkaniu z kalendarza pozycja magazynu ma `groupContext.threadId` równy wątkowi spotkania z `teams_list_meetings`; (5) `POST` dołączenia kończy się, gdy sonda odpowiada na wywołania zwrotne Trouter dopiero po jego końcu (jak moduł, 4.1); (6) jak długo oficjalny klient pokazuje drugie urządzenie, gdy sonda zniknie bez `leave`; (7) po przebiegu, w którym przyszły napisy, właściciel pyta innych uczestników, co widzieli; (8) - usunięty: `at` to czas przyjścia (4.2); (9) czy ponowne połączenie Trouter przez `reconnectUrl` zachowuje `surl`. Wyniki do "Live results". Bramki: (3), (4) albo (5) zawodzi = spec wraca do review; (7) ktoś coś widział = decyzja O2 przed krokiem 1. +1. Host: import `rtc` z testami (pętla zwrotna z drugim hostem WebRTC w teście z certyfikatem ECDSA P-384, oferta bez ścieżki, zero pakietów RTP od hosta, odrzucenie odpowiedzi, która wysyłałaby dźwięk do hosta, kandydaci tylko z `media.peers`, limity). 1b. Host i rdzeń: eksport `end-sessions` (4.3) - wywołanie w `daemon.rs` przy zatrzymaniu, w `accounts.rs` `rescan_modules` na uchwytach zwróconych przez `adopt`, w `update/quiesce.rs` przed wstrzymaniem zdarzeń, limit 5 s; `restore` po awarii modułu ze `state_changes`; testy na `test-echo` (eksport zapisuje wywołanie w `storage`) - recenzja R8. 2. Rdzeń: narzędzia piszące modułów (5.1) z testami na `test-echo` (narzędzie piszące z `conversation`: zgoda, odcisk z rozmowy i tytułu, unieważnienie po zmianie tytułu, brak unieważnienia po zmianie uczestników, `Gate`, strażnik podmiany modułu; recenzja R7). 3. Teams: Trouter z testami na fałszywym serwerze; `crates/open-mess-host/tests/teams/fake.rs` obsługuje dziś tylko HTTP, więc dostaje WebSocket (axum `ws`) dla Trouter. -4. Teams: `teams_join_captions`, `teams_read_captions`, `teams_leave_captions` z testami na fałszywym serwerze i nagranych, oczyszczonych wiadomościach kanału danych. -5. Test akceptacyjny na żywo przy właścicielu (sekcja 9). +4. Teams: `teams_join_captions`, `teams_read_captions`, `teams_leave_captions` z testami na fałszywym serwerze i nagranych, oczyszczonych wiadomościach kanału danych; `sdp.rs` z testami na oczyszczonych SDP z kroku 0b; `wam-token` z opcjonalnym `account` (5.2: `wit/module.wit`, `sign_in.rs`) i nazwanie konta w zdarzeniach (`component.rs`, `poll.rs`), z testem dwóch kont Windows (GLM R22); `read_messages` bez niepełnych list (4.5, `tools.rs` `messages`); opisy `teams_read_captions` i `get_transcript` ze zdaniem o czacie spotkania (4.5). +5. Test akceptacyjny na żywo przy właścicielu (sekcja 9), także odczyt czatu spotkania w trakcie sesji (4.5). ## 8. Dotknięte pliki - `wit/module.wit` (interfejs `rtc`, zdarzenia, pole `media` manifestu, `end-sessions`, argument `account` w `wam-token`), -- `AGENTS.md` (zdanie o importach hosta: dochodzi `rtc` dla modułów z `media`; recenzja R10), -- `crates/open-mess-host/src/rtc.rs` (nowy), `ws.rs` i `timers.rs` (bez zmian w przypisaniu zdarzeń), `sign_in.rs` i `wit/module.wit` (`wam-token` z opcjonalnym `account`), `modules/teams/src/component.rs` i `poll.rs` (odpytywanie nazywa konto), `bindings.rs`, `runtime.rs` (manifest `media`, `end-sessions` przed zwolnieniem instancji), `actor.rs` (zdarzenia `rtc`, `end-sessions`), `limits.rs`, `Cargo.toml` (biblioteka WebRTC), `deny.toml` jeśli licencje wymagają, testy w `crates/open-mess-host/tests/rtc.rs` (nowy), +- `AGENTS.md` (zdanie o importach hosta: dochodzi `rtc` dla modułów z `media`, recenzja R10; `proxy.rs` na liście wyjątków `unsafe`, recenzja R18), +- `crates/open-mess-host/src/rtc.rs` (nowy), `proxy.rs` (nowy, WinHTTP, 5.2), `ws.rs` i `timers.rs` (bez zmian w przypisaniu zdarzeń), `sign_in.rs` i `wit/module.wit` (`wam-token` z opcjonalnym `account`), `modules/teams/src/component.rs` i `poll.rs` (odpytywanie nazywa konto), `bindings.rs`, `runtime.rs` (manifest `media`, `end-sessions` przed zwolnieniem instancji), `actor.rs` (zdarzenia `rtc`, `end-sessions`, pomijanie żądań, na które nikt już nie czeka - recenzja R17), `limits.rs`, `Cargo.toml` (webrtc-rs 0.21), `deny.toml` jeśli licencje wymagają, testy w `crates/open-mess-host/tests/rtc.rs` (nowy), - `crates/open-mess/src/update/quiesce.rs` (`end-sessions` przed wstrzymaniem zdarzeń), `crates/open-mess/src/accounts.rs` (`rescan_modules` woła `end-sessions` na uchwytach z `adopt`; `restore` po awarii modułu ze `state_changes`), `crates/open-mess/src/tools/extra.rs`, `tools/mod.rs`, `pending.rs`, `approval.rs`, `modules.rs` (`canonical` z `media`, `end-sessions` w `adopt`), `daemon.rs` (`end-sessions` modułów przy zatrzymaniu), `ui/commands.rs` i `ui/app.js` (zgoda: `media`, narzędzia piszące), testy w `crates/open-mess/tests/shared_tools.rs`, `tests/pending.rs`, `tests/ui.rs`, - `modules/test-echo/src/lib.rs` (narzędzie piszące z `conversation`, użycie `rtc` w testach hosta), -- `modules/teams/src/manifest.rs`, `api.rs`, `component.rs` (nowe eksporty `end-sessions`, `on-rtc-*`), nowe `trouter.rs` i `captions.rs`, `tools.rs`, testy w `crates/open-mess-host/tests/teams/`, +- `modules/teams/src/manifest.rs` (także opis `get_transcript`, 4.5), `api.rs`, `component.rs` (nowe eksporty `end-sessions`, `on-rtc-*`), nowe `trouter.rs`, `captions.rs` i `sdp.rs`, `tools.rs` (także `messages` dla `read_messages`, 4.5), testy w `crates/open-mess-host/tests/teams/`, - `modules/test-spin/src/lib.rs`, `modules/test-hog/src/lib.rs` (te same nowe eksporty i pole `media` manifestu, puste - inaczej goście się nie skompilują; recenzja R2), -- `docs/protocols/teams.md` (10.7 z kroku 0a, "Live results" z kroku 0b). +- `docs/protocols/teams.md` (10.7 z kroku 0a, "Live results" z kroków 0b i 0c). ## 9. Kryteria akceptacji - Na testowym spotkaniu właściciela: `teams_join_captions` (po zgodzie w oknie), potem `teams_read_captions` co kilka sekund zwraca fragmenty z mówcami i czasami w ciągu kilku sekund od wypowiedzi; `teams_leave_captions` kończy sesję, a oficjalny klient właściciela przestaje pokazywać drugie urządzenie. -- Host nigdy nie negocjuje aktywnego dźwięku ani obrazu (test: odpowiedź z `sendrecv` audio = odmowa), łączy się tylko z TURN z listy hostów (test: przekaźnik spoza listy i adres prywatny = odmowa bez połączenia), trzyma limity wiadomości i czasu (testy). +- Na tym spotkaniu wiadomość napisana na czacie spotkania w trakcie sesji jest w `read_messages` tej rozmowy z `since` od razu po jej wysłaniu, z `sent_at` na tej samej osi co `at` napisów (sekundy różnicy), a stan "przeczytane" czatu się nie zmienia; opisy `teams_read_captions` i `get_transcript` w manifeście wskazują `read_messages`, dalsze strony z `before` i granice terminu spotkania cyklicznego (test manifestu; 4.5). +- `read_messages`, któremu skończy się budżet wywołania albo `MAX_PAGES` przed `since` i `limit` (także na stronach samych wiadomości systemowych), daje błąd platformy, nigdy krótszą listę (testy na fałszywym serwerze; 4.5). +- Host nigdy nie wysyła ani nie odbiera dźwięku i obrazu (testy: zero pakietów RTP od hosta; odpowiedź z sekcją audio albo wideo = odmowa; RTP od drugiego końca nie dociera do modułu), łączy się tylko z kandydatami z `media.peers` (test: kandydat spoza zakresów i adres prywatny = odrzucony, żaden dozwolony = odmowa bez połączenia), weryfikuje certyfikat DTLS ECDSA P-384 z SHA-256 (test), trzyma limity wiadomości i czasu (testy). - Narzędzie piszące modułu: prawo `never` = odmowa, `confirm` = zgoda w oknie z opisem z manifestu i uczestnikami, zmiana tytułu rozmowy po zgodzie = `voided`, a dołączenie nowych osób do czatu spotkania w trakcie zgody nie unieważnia jej, `autonomous` = też okno zgody, nigdy od razu; w trakcie aktualizacji zablokowane (`Gate`); po podmianie modułu na wersję z innym narzędziem = `failed` (testy z `test-echo`). - Prawo czytania cofnięte w trakcie sesji: `teams_read_captions` = "not allowed", a sesja kończy się sama najpóźniej po 10 minutach (test z udawanym czasem). -- Wynik `teams_read_captions` poza `untrusted` ma tylko wartości z ustalonych zbiorów, przepisane czasy i numery (test z tekstem w polach serwera). +- Wynik `teams_read_captions` poza `untrusted` ma tylko wartości z ustalonych zbiorów, przepisane czasy i numery oraz żeton `session` nadany przez moduł (test z tekstem w polach serwera). - Złe wiadomości kanału danych, wrogie koperty Trouter, ogromne SDP: błąd albo pominięcie, nigdy panika (testy regresji). - Nikt nie ma włączonych napisów: sesja `failed`, `reason: "no_captions"` i `leave` po 30 s; napisy wyłączone w trakcie: `captions_on: false`, a po 5 minutach koniec sesji (testy na fałszywym serwerze). -- Użytkownika nie ma na spotkaniu na innym urządzeniu: gdy krok 0a da sprawdzenie przed dołączeniem - akcja `teams_join_captions` jest `failed` z "join the meeting in Teams first"; gdy nie - akcja jest `done`, open_mess od razu opuszcza spotkanie, a `teams_read_captions` daje `state: "failed"`, `reason: "user_not_in_meeting"` (recenzja R3); w obu przypadkach open_mess nie zostaje w spotkaniu; ostatnie inne urządzenie użytkownika odchodzi w trakcie sesji = sesja się kończy (testy na fałszywym serwerze). +- Użytkownika nie ma na spotkaniu na innym urządzeniu: brak pozycji w magazynie połączeń = akcja `teams_join_captions` `failed` z "Teams does not list you in this meeting yet; join it in Teams first, or try again in a moment", bez dołączenia; pozycja jest, ale po dołączeniu lista uczestników nie ma innego urządzenia użytkownika = open_mess od razu opuszcza spotkanie, a `teams_read_captions` daje `state: "failed"`, `reason: "user_not_in_meeting"`; ostatnie inne urządzenie użytkownika odchodzi w trakcie sesji = sesja się kończy (testy na fałszywym serwerze). - Cisza na spotkaniu dłuższa niż kilka minut nie kończy sesji (test z udawanym czasem). +- Trouter zerwany, a w tym czasie odchodzi ostatnie inne urządzenie użytkownika: sesja kończy się w ciągu 10 s z `reason: "user_left"` i `leave`, bez czekania na ponowne połączenie (test na fałszywym serwerze z udawanym czasem; Codex R24). - Najwyżej jedna sesja na konto, dwa konta naraz działają; koniec spotkania, 10 min bez odczytu, 3 h, wylogowanie, wyłączenie modułu i zatrzymanie demona kończą sesję i opuszczają spotkanie (testy na fałszywym serwerze). +- Nieudany `leave` (5xx): rekord zostaje, ponowienie co 30 s do sukcesu albo 404, najwyżej 10 minut (test na fałszywym serwerze z udawanym czasem). - Awaria modułu w trakcie sesji: nowa instancja wysyła `leave` z rekordu w `storage`; zatrzymanie demona i podmiana modułu wołają `end-sessions`, który wysyła `leave` (testy na fałszywym serwerze i `test-echo`). +- Zatwierdzone wywołanie narzędzia modułu, które czekało w kolejce modułu dłużej niż limit wywołania: akcja `uncertain`, a aktor go już nie uruchamia (test z `test-echo` zajętym długim wywołaniem; recenzja R17). - Ponowne dołączenie do tej samej rozmowy: nowy `session`, numeracja od 0, `cursor` starej sesji = "the captions session changed"; zalew wiadomości w trakcie wolnego wywołania modułu: wiadomości dochodzą partią po wywołaniu, a ponad limit kolejki - `missed` > 0 (testy). - Zgoda na dołączenie do spotkania z 60 uczestnikami da się zatwierdzić w oknie konfiguracji (test). - W trakcie ponownego skanu, aż do końca `end-sessions` starej wersji, narzędzie piszące modułu jest odrzucane, także gdy w tym czasie ruszy drugi skan; po skanie działa (test z `test-echo`, którego `end-sessions` czeka na sygnał; recenzja commita 0b2b0ff). - Rozmowa z `read=false`, `write=confirm`: `teams_join_captions` daje okno zgody, nie odmowę czytania (test). - Brak kanału danych 60 s po przyjęciu żądania: sesja `failed` i `leave`; awaria modułu przy obsłudze zdarzenia bez dalszych wywołań: rdzeń woła `restore`, nowa instancja wysyła `leave`; nieudane odnowienie Trouter: sesja `failed`, `reason: "protocol"` (testy na fałszywym serwerze i `test-echo`). -- Proxy systemowe dla hosta przekaźnika: `rtc.open` = odmowa bez połączenia, `reason: "proxy"` (test). +- Proxy systemowe dla adresów drugiego końca: `rtc.accept` = odmowa bez połączenia, `reason: "proxy"`; decyzja dla wyników rozstrzygania (bez proxy, proxy, brak skryptu przy autowykrywaniu, skrypt nie do wykonania) testowana na udawanych wynikach WinHTTP (test). - open_mess nigdy nie wysyła poleceń do bota napisów, nie dodaje go do połączenia i nie zmienia języka mówienia ani ustawień spotkania (test: żadne żądanie na `commandUrl` bota ani dodanie uczestnika; O3). - Oficjalny klient Teams działa dalej; stan "przeczytane" czatu spotkania bez zmian. ## 10. Otwarte decyzje właściciela -- **O1. Widoczność u właściciela - rozstrzygnięte 2026-10-29: (a).** Póki open_mess jest na spotkaniu, oficjalny klient właściciela pokazuje "You're in this meeting on another device" i proponuje przeniesienie lub dodanie urządzenia (docs 10.5). Właściciel zgodził się na ten wyjątek od "Never disturb the official clients" tylko dla sesji napisów, które sam zatwierdza (4.1); wyjątek jest w AGENTS.md. Nic innego w oficjalnym kliencie się nie zmienia. -- **O2. Widoczność u innych** - tylko jeśli krok 0b pokaże, że inni uczestnicy widzą dodatkowe urządzenie, dźwięk albo powiadomienie: czy to akceptowalne. +- **O1. Widoczność u właściciela - rozstrzygnięte 2026-10-29: (a).** Póki open_mess jest na spotkaniu, połączenie ma drugie urządzenie użytkownika; kod oficjalnego klienta pokazuje wtedy "You're in this meeting on another device" i proponuje przeniesienie lub dodanie urządzenia, gdy na tym urządzeniu użytkownika nie ma w połączeniu (docs 10.5, analiza statyczna) - będąc na spotkaniu, właściciel tego komunikatu nie widział (krok 0c, Live results 18). Właściciel zgodził się na ten wyjątek od "Never disturb the official clients" tylko dla sesji napisów, które sam zatwierdza (4.1); wyjątek jest w AGENTS.md. Nic innego w oficjalnym kliencie się nie zmienia. +- **O2. Widoczność u innych - bez decyzji po kroku 0c.** Inni uczestnicy, zapytani przez właściciela, nic nie widzieli: po przebiegach bez kanału danych (krok 0b, Live results 15) i po przebiegach z napisami na spotkaniu zespołu właściciela, który wiedział o testach (krok 0c, 2026-11-02, Live results 18). Decyzja byłaby potrzebna tylko, gdyby ktoś zobaczył dodatkowe urządzenie, dźwięk albo powiadomienie. - **O3. Włączanie napisów - rozstrzygnięte 2026-10-29: (b).** open_mess czyta napisy tylko, gdy ktoś (np. właściciel w oficjalnym kliencie) już je włączył; inaczej błąd "turn on live captions in Teams first" (4.1). Nigdy nie dodaje bota napisów ani nie wysyła mu poleceń. ## 11. Threat model - **Zaufani**: użytkownik przy komputerze, rdzeń (spec 15). -- **Częściowo zaufani - agenci**: napisy to mowa innych osób, nośnik prompt injection; cała treść pod `untrusted` z notką; dołączenie tylko jako zapis z prawem zapisu i zgodą (6); opuszczenie każdy agent z prawem czytania może zawsze, bo tylko cofa obecność. +- **Częściowo zaufani - agenci**: napisy i czat spotkania to mowa i tekst innych osób, nośnik prompt injection; cała treść pod `untrusted` z notką (czat przez `read_messages`, 4.5); dołączenie tylko jako zapis z prawem zapisu i zgodą (6); opuszczenie każdy agent z prawem czytania może zawsze, bo tylko cofa obecność. - **Niezaufane**: usługa rozmów, Trouter, bot, wiadomości kanału danych, SDP - ścisłe dekodowanie, limity; do bota nie idzie nic (O3), a token Skype i AAD nigdy do hosta spoza listy. -- **Moduł**: nowy import `rtc` poszerza to, co moduł może zrobić - host ogranicza go do kanału danych przez przekaźniki TURN z listy hostów, bez mediów, z limitami; zgoda pokazuje hosty przekaźników i zakresy `media.peers`. Moduł nie wskaże dowolnego adresu: polityka ICE `relay` i uprawnienia TURN tylko dla `media.peers` (5.2). +- **Moduł**: nowy import `rtc` poszerza to, co moduł może zrobić - host ogranicza go do kanału danych, bez wysyłania i odbioru mediów (gwarantuje to lokalny opis hosta, nie SDP od modułu), tylko do adresów z `media.peers`, z limitami; zgoda pokazuje zakresy `media.peers`. Moduł nie wskaże dowolnego adresu (5.2). +- **Przejęte konto**: kto ma tokeny użytkownika, może dołączyć jako jego urządzenie, także pod jego nieobecność - na liście uczestników jako on (Live results 17). To ryzyko konta, nie open_mess: open_mess nie wyciąga tokenów, trzyma je jak etapy 1-2, a dołączenie zawsze czeka na zgodę w oknie. +- **Ujawnienia sieciowe**: oferta niesie lokalny adres komputera (kandydat host) - tylko do usługi Teams, jak u każdego klienta Teams. Rejestracja urządzenia sprawia, że w trakcie sesji Trouter dostarcza do open_mess powiadomienia czatu - moduł je pomija, nie czyta i nie zapisuje. - **Inni uczestnicy**: open_mess nie pokazuje im niczego, czego nie pokazałby drugi telefon właściciela w trybie bez dźwięku; nie zmienia języka ani ustawień (4.4); nie włącza napisów i nie dodaje bota (O3). - **Oficjalny klient**: open_mess nie czyta jego danych ani okna, nie używa jego identyfikatorów urządzenia; jedyny wpływ to komunikat o drugim urządzeniu (O1). - **Granica twardnienia**: jak spec 15 (bez obrony przed złośliwym kodem na koncie Windows). ## 12. Rejestr rebutali -(pusty) +- **Codex R20 P2, stronicowanie `before` gubi wiadomość z tą samą milisekundą** (`tools.rs` `read_messages` pomija wszystko od czasu wiadomości z `before`): odrzucone dla tego specu. To zachowanie narzędzia wspólnego z etapu 1, którego 4.5 nie zmienia; wymaga dwóch wiadomości czatu z tym samym `originalarrivaltime` co do milisekundy dokładnie na granicy strony, a odczyt z `since` co kilka sekund nie stronicuje (4.5). Poprawka (pomijać tylko wiadomości nowsze i samą wiadomość z `before`) zależy od tego, czy usługa czatu z `endTime` w ogóle oddaje wiadomości z tą samą milisekundą - tego nie sprawdzono (`chat.rs`, `read_messages`), więc to osobna zmiana narzędzia wspólnego po sprawdzeniu na żywo. ## Changelog rund @@ -215,3 +246,30 @@ i w `events`: `on-rtc-data(conn: u32, messages: list>, dropped: u32)`, - Po R11, recenzja commita add1643 (Codex REJECT, GLM APPROVE): ponowny skan modułów blokuje zapisy modułów do końca `end-sessions` starej wersji, więc podmiana nie daje dwóch sesji jednego konta (Codex P2). - Recenzja commita 0b2b0ff (Codex REJECT, GLM APPROVE): skany modułów po kolei pod jednym zamkiem, drugi skan nie zdejmie blokady pierwszego (Codex P2); test blokady w oknie skanu (GLM P3). - Recenzja commita 61091e3 (Codex REJECT, GLM APPROVE): narzędzia piszące modułów zawsze czekają na zgodę, `autonomous` działa jak `confirm`, bo O1 dotyczy sesji zatwierdzonych przez właściciela (Codex P2). +- Po kroku 0b (2026-10-30, przed R12): protokół z 3 zastąpiony wynikami na żywo (Live results 13-16); `rtc` bez przekaźników TURN, bez `send` i `add-candidate`, oferta stała z audio bez ścieżki (kierunek z kroku 0c), MS-SDP w module (`sdp.rs`), poprawka DTLS dla P-384; obecność użytkownika i adres dołączenia z magazynu połączeń przed dołączeniem; granica wywołania - akcja `done` po otwarciu Trouter, dołączenie w zdarzeniach; krok 0c (powtórka na żywo) z bramkami; recenzja commita b5bc0df (Codex P2: oferty bez `sendonly` nie były sprawdzone z poprawną sekcją danych). +- R12 (Codex APPROVE, GLM REJECT): limit 60 s obejmuje też czekanie na `trouter.connected` po otwarciu Trouter (GLM P2); `at` z `timestampAudioSent` według jednostek z kroku 0c, punkt (8) kroku 0c (GLM P3). +- R13 (Codex REJECT, GLM APPROVE): przerwa w Trouter albo `trouter.message_loss` wstrzymuje napisy do ponownego odczytu listy uczestników, bez innego urządzenia użytkownika - `leave` (Codex P2); limit połączeń sprawdzany przed otwarciem Trouter, znaczenie `uncertain` akcji dołączenia, spóźnione odpowiedzi na Trouter (GLM P3). +- R14 (Codex REJECT, GLM REJECT): blokada zapisów modułów jako licznik, skan kończący się w fazie A nie otwiera zapisów (Codex P2); zmieniony `surl` po ponownym połączeniu Trouter kończy sesję z `leave`, punkt (9) kroku 0c (GLM P2); `language` z ostatniego fragmentu strony, żeton `session` w kryterium wyniku (GLM P3). +- R15 (Codex REJECT, GLM APPROVE): `run` sprawdza prawo zapisu ponownie, `never` po utworzeniu akcji = `voided` (Codex P2); powód `protocol` dla błędów sygnalizacji, ponowne odczyty magazynu połączeń (GLM P3). Do tego wyniki testu widoczności (Live results 17): noga nigdy nie jest ukryta, bez użytkownika udaje jego obecność; zagrożenie przejętego konta w threat modelu. +- R16 (Codex REJECT, GLM APPROVE): adres dołączenia z magazynu połączeń tylko ze schematem `https://` (Codex P2); licznik bezczynności tylko po udanym odczycie, strona z jednym fragmentem ponad `max_bytes`, stary `session` bez `cursor` (GLM P3). +- R17 (Codex REJECT, GLM APPROVE): aktor pomija żądania, na które wywołujący już nie czeka, a `uncertain` akcji dołączenia znaczy "mogło się zacząć" (Codex P2); status w nagłówku, heartbeat Socket.IO w 5.3 (GLM P3). +- R18, za zgodą właściciela (Codex REJECT, GLM APPROVE bez uwag): `rtc` rozstrzyga proxy przez WinHTTP z PAC i WPAD, skrypt nie do wykonania = odmowa, brak skryptu przy autowykrywaniu = bezpośrednio (Codex P2). Po recenzji commita 3b2c742 (Codex i GLM APPROVE, P3): `at` to czas przyjścia fragmentu, punkt (8) kroku 0c usunięty; bez wyświetlanej nazwy konta w docs. +- Po kroku 0c (2026-11-02, bez recenzji): oferta `rtc` to sam kanał danych, `accept` odrzuca każdą sekcję audio i wideo; droga (b) bez `notificationLinks`; usługa nie czeka na odpowiedzi Trouter w trakcie `POST`; `trouter.message_loss` obsługiwany w tym samym wywołaniu zdarzenia; noga bez `leave` nie wygasa sama w 10 minut, więc rekord sesji niesie `endpointId` i `participantId`, a opisy w oknie (moduł zatrzymany, usunięte konto) mówią o widoczności do końca spotkania; komunikat "on another device" to tylko analiza statyczna, na żywo go nie było (O1); O2 bez decyzji. +- R19, za zgodą właściciela (Codex APPROVE, GLM APPROVE, obaj bez uwag): poprawka R18 i zmiany po kroku 0c przyjęte bez zmian. Koniec recenzji specu. +- Po recenzji commita 69eb728 (Codex APPROVE, GLM APPROVE z P3): opis w oknie zgody nie obiecuje komunikatu "on another device"; komunikat przy braku pozycji w magazynie połączeń mówi też "try again in a moment", a brak `groupContext` na zaplanowanym spotkaniu to świadome ograniczenie; rekord sesji starszy niż 4 h też dostaje `leave` (GLM P3). +- Krok 1 (commit b70e9bb, recenzja Codex P2): po `accept` gniazdo przepuszcza tylko adresy kandydatów, które przeszły sprawdzenie proxy (peer-reflexive z zakresu bez sprawdzenia proxy - odrzucony). +- Krok 1 (b70e9bb): `om.exe` w release urósł z 43,7 MiB do 47,9 MiB (+4,2 MiB, webrtc-rs 0.21); `cargo deny` bez uwag po scaleniu z main (187e2be). Krok 1b: `end-sessions` przy zatrzymaniu demona, ponownym skanie i przed fazą A aktualizacji, `restore` zaraz po awarii modułu; blokada zapisów modułów z 4.3 przychodzi z krokiem 2 razem z narzędziami piszącymi. +- Krok 2 (bez recenzji specu, zmiany wykonawcze): narzędzie piszące modułu w kolumnie `kind` jako `module:`, a odcisk to dwie części `/`: inny tytuł = `voided`, inna deklaracja w module konta = `failed` ("the module's tool changed"). `run` bierze uchwyt modułu i deklarację na nowo dopiero pod licznikiem blokady, więc podmiana w trakcie ponownego opisu celu też nie wywoła innego narzędzia. Blokada zapisów modułów (licznik blokad i licznik trwających wywołań modułu w `Accounts`) odrzuca z "the modules are being updated or reloaded; nothing was done - try again shortly", bo dotyczy też skanu, nie tylko aktualizacji. Dopisek przy uczestnikach w oknie zgody jest ogólny ("Zgoda dotyczy rozmowy, nie tylko osób, które są w niej teraz."), a o napisach mówi opis narzędzia z manifestu Teams: rdzeń nie zna napisów. Aktor pomija żądania, na które nikt nie czeka, poza `end-sessions` i `sign-out`, które cofają to, co widzą inni, więc wykonują się zawsze. +- Krok 3 (bez recenzji specu, zmiany wykonawcze): Trouter to czysty `modules/teams/src/trouter.rs` (ramki, czasy i wyniki wchodzą, ramki i żądania wychodzą) z testami natywnymi na skryptowanym serwerze. Klej do hosta (gniazdo, timer, rejestracja przez `api`) i WebSocket w `crates/open-mess-host/tests/teams/fake.rs` przechodzą do kroku 4: bez `teams_join_captions` nic by ich nie wywołało, więc byłyby martwym kodem. Wartości zapytania v4c, odpowiedzi na żądania (`headers: {}`), nagłówki i ciało rejestracji są takie, jakie wysyłała sonda na żywo (dopisane do docs, Live results 13), także `platform: "edge"` i `ua: "TeamsCDL"` - do sprawdzenia w kroku 5, czy usługa przyjmie uczciwe wartości. Poza odsyłaniem `2::` moduł co 15 s wysyła ping (`5:+::{"name":"ping"}`), jak sonda. Rejestracja jest odnawiana po 600 s (TTL 900 s), nieudane odnowienie co 15 s, a po 2 minutach koniec sesji; ponowne łączenie czeka 1, 2, 4, 8, 16, potem 30 s. `reconnectUrl` tylko na `*.trouter.teams.microsoft.com`, `registrarUrl` tylko na `teams.microsoft.com` (https, port 443), inaczej adresy domyślne - token nie idzie nigdzie indziej. Skompresowane wywołanie zwrotne (moduł o nie nie prosi) dostaje 400 i liczy się jak `trouter.message_loss`. +- Przed R20 (decyzja właściciela z 2026-11-03, sekcja 2): czat spotkania w kontekście spotkania (4.5) - bez drugiego czytnika: `read_messages` tej samej rozmowy z `since`, pod tym samym prawem czytania i `untrusted`; opisy `teams_read_captions` i `get_transcript` mówią o tym agentowi; wariant z czatem w wyniku `teams_read_captions` odrzucony. +- R20 (Codex REJECT, GLM APPROVE bez uwag): spotkanie cykliczne ma jeden wątek czatu, więc opisy mówią agentowi o granicach terminu (`start` i `end` z `list_meetings`, `since`) (Codex P2); stronicowanie `before` przy wiadomościach z tą samą milisekundą - rebutal, spec nie obiecuje już, że `before` niczego nie gubi (Codex P2). +- R21 (Codex REJECT, GLM martwy - wyczerpany limit do 2026-11-04 21:11): nieudany `leave` w działającej instancji ponawiany co 30 s przez 10 minut, rekord zostaje dla następnej instancji (Codex P2); edycja wiadomości czatu nie zmienia `sent_at`, więc opis mówi agentowi, żeby po aktualny tekst czytał od początku spotkania (Codex P2). +- R22 (Codex REJECT, GLM APPROVE z P3): wynik `read_messages` bywa krótszy niż `limit` mimo dalszych wiadomości (budżet wywołania), więc z `since` agent czyta dalej z `before` aż do pustego wyniku i dopiero wtedy przesuwa `since`; opisy to mówią (Codex P2); `wam-token(account)` przypisany do kroku 4 (GLM P3). +- R23 (Codex REJECT, GLM APPROVE bez uwag): zamiast reguły "czytaj do pustego wyniku" (pusta lista po stronie samych wiadomości systemowych też może być niepełna) `read_messages` w kroku 4 nie oddaje już niepełnych list - po budżecie albo `MAX_PAGES` daje błąd, a dalsze strony agent czyta, gdy wynik ma `limit` wiadomości (Codex P2); usunięcie rekordu sesji dopiero po udanym `leave` (albo 404 i 410) na każdej ścieżce, także w nowej instancji po awarii, z ponawianiem w działającej instancji (Codex P2). +- R24 (Codex REJECT, GLM APPROVE z P3): w przerwie Trouter moduł czyta listę uczestników przez HTTPS co 10 s, więc odejście użytkownika kończy sesję także wtedy (Codex P2); wyjątek `end-sessions` i `sign-out` od pomijania porzuconych żądań w 4.1, stan kodu w sekcji 3, usunięta wiadomość z `before` (GLM P3). +- R25 (Codex APPROVE bez uwag, GLM APPROVE z P3): teksty dopisku zgody (5.1) i odmowy w trakcie blokady zapisów (4.3) jak w kodzie z kroku 2 (GLM P3). Koniec recenzji zmiany z 4.5. +- Krok 4 (bez recenzji specu, zmiany wykonawcze): czysty rdzeń sesji w `captions.rs` (stan, terminy, dekoder kanału danych, bufor, ciała i odpowiedzi usługi rozmów, rekordy) i `sdp.rs`, klej w `live.rs`. Przy każdym `rosterUpdate` moduł czyta całą rozmowę (`GET `) zamiast składać delty, których znaczenia przy wyjściu urządzenia nikt nie sprawdził; odpowiedź na dołączenie też niesie listę uczestników i jest od razu sprawdzana. Poczekalnia, E2EE i polityka nie mają jeszcze własnego sygnału: odmowa dołączenia (4xx) to `reason: "refused"`, więc powody `lobby`, `e2ee` i `policy` się nie pojawiają. Ciało dołączenia, nagłówki i SDP są takie, jakie wysyłała sonda (dopisane do docs, Live results 18), także `clientLocation: "PL"` sondy - do sprawdzenia w kroku 5, czy Teams patrzy na tę wartość. Rekordy sesji to lista na konto (`captions-legs:`, najwyżej 8 najnowszych); identyfikator urządzenia jest na konto (`captions-endpoint:`); timer sesji to jeden identyfikator na konto od 100 w górę. Usługa rozmów dostaje token Skype bez tokenu AAD, jak w sondzie; `MS-Teams-Region` i `MS-Teams-Partition` biorą `region` i `partition` z authsvc. `media.peers` to pozycja 11 listy adresów Microsoft 365 z 2026-08-14 (`52.112.0.0/14`, `52.122.0.0/15`, `2603:1063::/38`, docs 10.7). Na liście hostów doszedł `api.flightproxy.teams.microsoft.com`. Host w trybie testowym kieruje WebSockety modułów do fałszywego serwera (`Net::serving_websockets_from`); testy całego przebiegu na pętli zwrotnej (dołączenie, odpowiedź SDP z prawdziwym WebRTC, napisy z kanału danych, odczyt, `leave`, `end-sessions`, brak innego urządzenia użytkownika, brak pozycji w magazynie połączeń) idą z produkcyjnym limitem wywołania, bo dołączenie czeka na magazyn połączeń 2 razy po 5 s. Nie zrobione w kroku 4: ostrzeżenia w oknie konfiguracji o module zatrzymanym z rekordem sesji i o usuwaniu konta z aktywną sesją (4.3) - rdzeń nie widzi rekordów w `storage` modułu, to osobna zmiana. +- Po recenzji commita 96070fd (Codex REJECT, GLM APPROVE z P3): w przerwie Trouter udany odczyt listy uczestników nie kończy już odczytów co 10 s, a napisy czekają na powrót Trouter (Codex P1); 30 s od otwarcia kanału danych napisy muszą być włączone także wtedy, gdy były włączone przy dołączeniu i ktoś je wyłączył przed otwarciem kanału (Codex P2). Oferta hosta bez kandydata IPv4 (sieć tylko IPv6) kończy sesję `reason: "protocol"` - kroki 0b i 0c sprawdziły tylko IPv4, więc to świadome ograniczenie (GLM P3). +- Po recenzji commita 7385fdd (Codex REJECT, GLM REJECT): bez zapisanego rekordu sesji moduł nie dołącza (Codex P2); `leave` idzie przed usunięciem rejestracji, więc mieści się w 5 s `end-sessions` (Codex P2); `sign-out` próbuje `leave` dla każdego rekordu, także bez sesji, która go trzyma (Codex P2); sesja, dla której WAM przestał dawać tokeny, zostawia rekord i ponawianie (bez sesji ponawianie trwa dalej), a ponowne zalogowanie wysyła `leave` z rekordów (GLM P2); test: nieudany `leave` zostaje w rekordzie, `restore` i `sign-out` wysyłają go jeszcze raz (GLM P2). Odczyty listy uczestników w przerwie Trouter (Codex P2) naprawiła już poprawka po recenzji 96070fd. +- Po recenzji commita 263a526 (Codex REJECT): Trouter jest znowu połączony także wtedy, gdy pierwsze gniazdo padło przed `trouter.connected` (Codex P2); odczyt listy uczestników w przerwie Trouter liczy powrót napisów, więc 5 minut bez napisów nie biegnie dalej (Codex P2). +- Krok 5 (2026-11-04, test na żywo na zaplanowanym spotkaniu zespołu właściciela, za jego zgodą w czacie; Live results 19): napisy przyszły - 19 fragmentów od 4 mówców w około 1,5 minuty, każdy odczytany 2-3 s po przyjściu, `missed` 0; wiadomość z czatu spotkania wróciła z `read_messages` z `since`; `teams_leave_captions` zakończyło sesję. Oficjalny klient właściciela nie pokazał niczego o drugim urządzeniu, a inni uczestnicy niczego nie zauważyli. Po drodze dwie poprawki: wywołania zwrotne przychodzą skompresowane (bit 128 w `endpointCapabilities` 72935 sondy), więc moduł je rozpakowuje (gzip albo zlib, najwyżej 1 MiB) zamiast odpowiadać 400 - na 400 usługa kończyła nogę (`/call/end/`, 430, 10067) - a odpowiedź SDP z samym kanałem danych niesie linie strumieni RTP (`a=ssrc-group`), które `sdp.rs` pomija razem z `a=ssrc` i `a=msid`; inne nieznane linie są nadal błędem i błąd wymienia je wszystkie. `/call/end/` to koniec nogi, nie spotkania: powód `refused`, a `meeting_ended` tylko dla `/conversation/conversationEnd/`; kod i sub-kod końca idą do logu. Do diagnozy demon dostał `OPEN_MESS_LOG` (logi demona i modułów na stderr, tylko stany, kody i liczby; domyślnie wyłączone), a moduł loguje kroki sesji. Wartości sondy (`platform: edge`, `ua: TeamsCDL`, `clientLocation: PL`) przeszły; uczciwych wartości nie sprawdzano. Kryterium "oficjalny klient przestaje pokazywać drugie urządzenie" nie dało się sprawdzić, bo klient go w ogóle nie pokazał. diff --git a/AGENTS.md b/AGENTS.md index 516401c..9a15492 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,9 @@ The daemon runs all day and holds the user's sessions; a crash drops every accou - `crates/open-mess-host/src/wam.rs`, function `request_for_window`: the WAM sign-in dialog, `IWebAuthenticationCoreManagerInterop::RequestTokenForWindowAsync`, an interop call the `windows` crate exposes only as `unsafe`. + - `crates/open-mess-host/src/proxy.rs`, module `winhttp` (functions `free` and `auto_config`): + the user's proxy settings and `WinHttpGetProxyForUrl` (PAC script and WPAD) for media + connections, and `GlobalFree` for the strings WinHTTP hands back. - **Errors are `Result`** through the crate's error type and `?`. An unfinished feature returns an error ("not supported yet: ..."); it never panics. - **Everything from the network is hostile:** server responses, WebSocket events, message @@ -75,7 +78,9 @@ The daemon runs all day and holds the user's sessions; a crash drops every accou - **One tool registry.** Every capability is a tool with the same name, parameters and results over MCP and the CLI. No MCP-only or CLI-only features. - **Modules reach the outside world only through host imports** (HTTP, WebSocket, storage, - secrets, sign-in prompts). A module's network access is limited to the hosts in its manifest. + secrets, sign-in prompts, and media connections (`rtc`) for a module whose manifest has + `media`). A module's network access is limited to the hosts in its manifest, and its media + connections to the address ranges in its `media`. - **One process.** No permanent helper processes and no browser engine running in the background. The configuration window's WebView exists only while the window is open. The only exception is `om mcp-stdio` (spec 8.7): a bridge Claude Code starts for a session with channels, diff --git a/Cargo.lock b/Cargo.lock index 6567d97..0ab2db3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + [[package]] name = "aes" version = "0.9.2" @@ -125,6 +146,73 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive 0.5.1", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive 0.6.0", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.21", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure 0.13.2", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure 0.13.2", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -343,6 +431,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base64" version = "0.21.7" @@ -361,13 +455,19 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] [[package]] @@ -376,6 +476,15 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -458,6 +567,29 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "bytecheck" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26333eeac754f0ad8a6bcd0eb0ac012156302e4e16b852b72ee399aea4f12c29" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "rancor", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "bytemuck" version = "1.25.2" @@ -565,6 +697,18 @@ dependencies = [ "shlex", ] +[[package]] +name = "ccm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae3c82e4355234767756212c570e29833699ab63e6ffd161887314cc5b43847" +dependencies = [ + "aead", + "cipher 0.4.4", + "ctr", + "subtle", +] + [[package]] name = "cesu8" version = "1.1.0" @@ -598,6 +742,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chacha20" version = "0.10.2" @@ -606,7 +756,7 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.1", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -973,6 +1123,30 @@ version = "0.137.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f34b169b8cbbaed2b4995205f1d02d7eac4b2771df5479255fe5483e1334a39" +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32c" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" +dependencies = [ + "rustc_version", +] + [[package]] name = "crc32fast" version = "1.5.3" @@ -1016,6 +1190,22 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crypto-bigint" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c035cb64cba03dad2fb921f3e218785e8cf9a41790da5eb3a1400f62fa2be11" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.3", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1023,6 +1213,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1032,7 +1223,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -1080,6 +1273,15 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "ctutils" version = "0.4.6" @@ -1087,6 +1289,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe3e9d21d39e6f39ff46e03d5b621424f88ee23d8a1672c6bfc9ccc7f2da4f6a" dependencies = [ "cmov", + "subtle", ] [[package]] @@ -1160,6 +1363,45 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "der" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs 0.6.2", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs 0.7.2", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.9" @@ -1346,12 +1588,48 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der", + "digest 0.11.3", + "elliptic-curve", + "rfc6979", + "signature", + "spki", + "zeroize", +] + [[package]] name = "either" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +[[package]] +name = "elliptic-curve" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900f37e7e18d74a2347389af6cd69e3a30d5cc9e697f40004865679aa75e9c7c" +dependencies = [ + "base16ct", + "crypto-bigint", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff", + "group", + "hybrid-array", + "pem-rfc7468", + "pkcs8", + "rand_core 0.10.1", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embed-resource" version = "3.0.12" @@ -1499,6 +1777,22 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "field-offset" version = "0.3.6" @@ -1790,7 +2084,7 @@ dependencies = [ "js-sys", "libc", "r-efi 6.0.0", - "rand_core", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -1902,6 +2196,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "gtk" version = "0.18.2" @@ -2117,7 +2422,9 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ + "subtle", "typenum", + "zeroize", ] [[package]] @@ -2766,6 +3073,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + [[package]] name = "memchr" version = "2.8.3" @@ -2796,6 +3113,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "minisign" version = "0.10.0" @@ -2872,6 +3195,26 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" +[[package]] +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2902,6 +3245,29 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num" version = "0.4.3" @@ -3210,17 +3576,35 @@ dependencies = [ ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "oid-registry" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs 0.6.2", +] [[package]] -name = "once_cell_polyfill" -version = "1.70.2" +name = "oid-registry" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs 0.7.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "open-mess" version = "0.0.0" @@ -3260,18 +3644,22 @@ dependencies = [ name = "open-mess-host" version = "0.0.0" dependencies = [ + "async-trait", "axum", "futures-util", "hyper-util", "keyring", "keyring-core", "open-mess-host", + "p384", "psl", "reqwest", + "rtc", "rusqlite", "rustls", "rustls-platform-verifier", "serde_json", + "sha2 0.11.1", "tempfile", "thiserror 2.0.21", "tokio", @@ -3281,6 +3669,7 @@ dependencies = [ "url", "wasmtime", "wasmtime-wasi", + "webrtc", "windows 0.62.2", "windows-future 0.3.2", ] @@ -3289,6 +3678,7 @@ dependencies = [ name = "open-mess-teams" version = "0.0.0" dependencies = [ + "flate2", "serde_json", "url", "wit-bindgen 0.63.0", @@ -3338,6 +3728,20 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "p384" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f" +dependencies = [ + "ecdsa", + "elliptic-curve", + "fiat-crypto", + "primefield", + "primeorder", + "sha2 0.11.1", +] + [[package]] name = "pango" version = "0.18.3" @@ -3408,6 +3812,35 @@ dependencies = [ "hmac 0.12.1", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3484,6 +3917,16 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -3601,6 +4044,33 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve", + "once_cell", + "primefield", + "serdect", + "wnaf", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -3678,6 +4148,26 @@ version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" +[[package]] +name = "ptr_meta" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743da816b98c921cdbe8628ef7381b76f25ecf4da599fc80aca90eae7ef70cc0" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "pulley-interpreter" version = "50.0.1" @@ -3716,6 +4206,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn-udp" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47091c86514c2e11452cb228dee77037334b99d92a23e4c69aa5982261f39247" +dependencies = [ + "cfg_aliases", + "libc", + "log", + "socket2", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -3737,6 +4240,15 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rancor" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b534442d0fcdb55d66f373d9cac6d33b6293a2335bc2136dbd06ce0e87d2572" +dependencies = [ + "ptr_meta", +] + [[package]] name = "rand" version = "0.10.3" @@ -3745,7 +4257,16 @@ checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -3780,6 +4301,20 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.14.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" +dependencies = [ + "pem 4.0.0", + "ring", + "rustls-pki-types", + "time", + "x509-parser 0.18.1", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3863,6 +4398,15 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rend" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "663ba70707f96e871406fe10d68128412e619b06d1d47cb91c3a4c6501176240" +dependencies = [ + "bytecheck", +] + [[package]] name = "reqwest" version = "0.13.5" @@ -3903,6 +4447,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint", + "hmac 0.13.0", +] + [[package]] name = "rfd" version = "0.17.2" @@ -3940,6 +4494,36 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rkyv" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9776093b7ca170454ab1406954f7b7d97a57c51dc6c0642957fb2ef25c2d399" +dependencies = [ + "bytecheck", + "bytes", + "hashbrown 0.17.1", + "indexmap 2.14.2", + "munge", + "ptr_meta", + "rancor", + "rend", + "rkyv_derive", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "rmcp" version = "3.7.0" @@ -3993,6 +4577,266 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "rtc" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e2ca201de5ccc83aa3c08d49a2f08d7ed0aeeb2052fb1ccc517a8b77a6927ce" +dependencies = [ + "bytes", + "hex", + "log", + "pem 3.0.6", + "rand", + "rcgen", + "rtc-crypto", + "rtc-datachannel", + "rtc-dtls", + "rtc-ice", + "rtc-interceptor", + "rtc-mdns", + "rtc-media", + "rtc-rtcp", + "rtc-rtp", + "rtc-sctp", + "rtc-sdp", + "rtc-shared", + "rtc-srtp", + "rtc-stun", + "rtc-turn", + "rustls", + "sansio", + "serde", + "serde_json", + "unicase", + "url", + "x509-parser 0.16.0", +] + +[[package]] +name = "rtc-crypto" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404ea28682271626739dfb1f3a04e96c596490b948d7629d9e4651dd4ba6aaf3" +dependencies = [ + "aes 0.8.4", + "ccm", + "ctr", + "hmac 0.12.1", + "md-5", + "rand", + "ring", + "sha1 0.10.7", + "subtle", + "thiserror 2.0.21", + "zeroize", +] + +[[package]] +name = "rtc-datachannel" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d93dd81014535ff0134c0f19dbccfbae4d5ceb6010cf917c1a6056ea2c0d9bb" +dependencies = [ + "bytes", + "log", + "rtc-sctp", + "rtc-shared", + "sansio", +] + +[[package]] +name = "rtc-dtls" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a2fe5767d6caf61018d52189b3b8e37075799eeb81cd5ab452307c7e7ebd97e" +dependencies = [ + "bytecheck", + "byteorder", + "bytes", + "der-parser 9.0.0", + "log", + "pem 3.0.6", + "rcgen", + "rkyv", + "rtc-crypto", + "rtc-shared", + "rustls", + "x509-parser 0.16.0", +] + +[[package]] +name = "rtc-ice" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "099984f1be120b2e5692f232f8cbb82e53f6b4261c5c2eeda5be6223ba1adaa9" +dependencies = [ + "bytes", + "crc", + "log", + "rtc-crypto", + "rtc-mdns", + "rtc-shared", + "rtc-stun", + "sansio", + "serde", + "url", + "uuid", +] + +[[package]] +name = "rtc-interceptor" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43e7df85d7aacf6e3f69bb922a6c9f83b2a29461038d06285df7642206df64cf" +dependencies = [ + "log", + "rand", + "rtc-rtcp", + "rtc-rtp", + "rtc-shared", + "sansio", +] + +[[package]] +name = "rtc-mdns" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d86ac3cafd411bef81d70aff9d59d9520331b29970f7e361180da99e7a8b5a0d" +dependencies = [ + "bytes", + "log", + "rtc-shared", + "sansio", + "socket2", +] + +[[package]] +name = "rtc-media" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a76840ad2aff52efeb16a613db26c16981841a6cf50907bff950884f00f9d9a9" +dependencies = [ + "byteorder", + "bytes", + "rand", + "rtc-rtp", + "rtc-shared", + "thiserror 2.0.21", +] + +[[package]] +name = "rtc-rtcp" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "485aa259bdb68e550363c40c8dc30c1c453ddd80f0cf7224dd2d72dffd5e01c2" +dependencies = [ + "bytes", + "rtc-shared", +] + +[[package]] +name = "rtc-rtp" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a640f266927139a899a3a93b5cf4aa3626594ee2e5d26202b60930bb42cb445" +dependencies = [ + "bytes", + "memchr", + "rand", + "rtc-shared", + "serde", +] + +[[package]] +name = "rtc-sctp" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb430b379d90e7984306036d1646454a26b300c9c3256009eb8f7e0615e90b9" +dependencies = [ + "bytes", + "crc32c", + "log", + "rand", + "rtc-shared", + "rustc-hash", + "slab", + "thiserror 2.0.21", +] + +[[package]] +name = "rtc-sdp" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd69b1ea06978e045f374ec19eb34177ee4e4f8349cc7d9c0b183683fa7b2d1f" +dependencies = [ + "rand", + "rtc-shared", + "url", +] + +[[package]] +name = "rtc-shared" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1250e81796ac6d3d67bd04ecdb891b8371ff97c8a2ce0a5a1c1321f2e12b657b" +dependencies = [ + "bitflags 1.3.2", + "bytes", + "nix", + "rand", + "serde", + "substring", + "thiserror 2.0.21", + "url", + "winapi", +] + +[[package]] +name = "rtc-srtp" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb71f476c84763db0435bc47a598acb31abc5fdc6ca3fc84dcb274ed9b70155" +dependencies = [ + "byteorder", + "bytes", + "rtc-crypto", + "rtc-rtcp", + "rtc-rtp", + "rtc-shared", +] + +[[package]] +name = "rtc-stun" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e59e5c70b7900f537ded24cfee5fa476e8fdc186b90ced528611e441f4063b4" +dependencies = [ + "base64 0.22.1", + "bytes", + "crc", + "lazy_static", + "rand", + "rtc-crypto", + "rtc-shared", + "sansio", + "url", +] + +[[package]] +name = "rtc-turn" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c53078a0696df5a3a5e4947bc550c5e903b59803b252f1d39062d637ebd3d17e" +dependencies = [ + "bytes", + "log", + "rtc-crypto", + "rtc-shared", + "rtc-stun", + "sansio", +] + [[package]] name = "rtoolbox" version = "0.0.6" @@ -4039,6 +4883,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.5" @@ -4165,6 +5018,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "sansio" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62751faa8bc286982334a082fe125184a29fc89d17775766e4f891b7d726980" + [[package]] name = "schannel" version = "0.1.30" @@ -4256,13 +5115,27 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der", + "hybrid-array", + "subtle", + "zeroize", +] + [[package]] name = "secret-service" version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" dependencies = [ - "aes", + "aes 0.9.2", "cbc", "futures-util", "getrandom 0.4.3", @@ -4489,6 +5362,16 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -4520,6 +5403,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1" version = "0.11.0" @@ -4578,6 +5472,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -4679,6 +5583,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sqlite-wasm-rs" version = "0.6.2" @@ -4738,6 +5652,15 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "substring" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ee6433ecef213b2e72f587ef64a2f5943e7cd16fbd82dbe8bc07486c534c86" +dependencies = [ + "autocfg", +] + [[package]] name = "subtle" version = "2.6.1" @@ -4796,6 +5719,17 @@ dependencies = [ "futures-core", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "synstructure" version = "0.14.0" @@ -5578,7 +6512,7 @@ dependencies = [ "rand", "rustls", "rustls-pki-types", - "sha1", + "sha1 0.11.0", "thiserror 2.0.21", ] @@ -5646,6 +6580,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + [[package]] name = "unicode-ident" version = "1.0.27" @@ -6246,6 +7186,24 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webrtc" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b621e736302bef784948cc8d8099fd0a8fdb5757693c80a344ffbe38c49387d2" +dependencies = [ + "async-broadcast", + "async-channel", + "async-trait", + "bytes", + "event-listener", + "futures", + "log", + "quinn-udp", + "rtc", + "tokio", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -6886,6 +7844,18 @@ dependencies = [ "wasmparser 0.262.0", ] +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff", + "group", + "hybrid-array", + "primefield", +] + [[package]] name = "writeable" version = "0.6.4" @@ -6934,6 +7904,41 @@ dependencies = [ "windows-version", ] +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs 0.6.2", + "data-encoding", + "der-parser 9.0.0", + "lazy_static", + "nom", + "oid-registry 0.7.1", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs 0.7.2", + "data-encoding", + "der-parser 10.0.0", + "lazy_static", + "nom", + "oid-registry 0.8.1", + "ring", + "rusticata-macros", + "thiserror 2.0.21", + "time", +] + [[package]] name = "xtask" version = "0.0.0" @@ -6944,6 +7949,16 @@ dependencies = [ "tempfile", ] +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec 0.9.1", + "time", +] + [[package]] name = "yoke" version = "0.8.4" @@ -6964,7 +7979,7 @@ dependencies = [ "proc-macro2", "quote", "syn 3.0.9", - "synstructure", + "synstructure 0.14.0", ] [[package]] @@ -7066,7 +8081,7 @@ dependencies = [ "proc-macro2", "quote", "syn 3.0.9", - "synstructure", + "synstructure 0.14.0", ] [[package]] diff --git a/crates/open-mess-host/Cargo.toml b/crates/open-mess-host/Cargo.toml index 9033acf..6a51a67 100644 --- a/crates/open-mess-host/Cargo.toml +++ b/crates/open-mess-host/Cargo.toml @@ -33,6 +33,10 @@ tokio = { version = "1.54.1", features = ["io-util", "macros", "net", "rt", "syn tokio-tungstenite = { version = "0.30.0", default-features = false, features = ["connect", "rustls-tls-native-roots"] } tracing = "0.1.44" url = "2.5.8" +# Module media connections (`rtc`): one data channel, no audio or video. 0.21 verifies the +# ECDSA P-384 certificate with SHA-256 signatures that Teams' media processor uses. +webrtc = { version = "0.21.1", default-features = false, features = ["runtime-tokio", "crypto-ring"] } +async-trait = "0.1.89" wasmtime = { version = "50.0.1", default-features = false, features = ["async", "component-model", "cranelift", "parallel-compilation", "runtime"] } wasmtime-wasi = { version = "50.0.1", default-features = false, features = ["p2"] } @@ -43,6 +47,7 @@ windows = { version = "0.62.2", features = [ "Security_Authentication_Web_Core", "Security_Credentials", "Win32_Foundation", + "Win32_Networking_WinHttp", "Win32_System_WinRT", ] } # WAM's async operations, waited for with `join`. @@ -51,6 +56,10 @@ windows-future = "0.3.2" [dev-dependencies] axum = "0.8.9" open-mess-host = { path = ".", features = ["test-support"] } +# The far end of the media tests: a certificate with the key and hash Teams uses (P-384, SHA-256). +rtc = "0.21.1" +p384 = "0.14.0" +sha2 = "0.11.1" serde_json = "1.0.154" tempfile = "3.27.0" tokio = { version = "1.54.1", features = ["macros", "rt-multi-thread"] } diff --git a/crates/open-mess-host/src/actor.rs b/crates/open-mess-host/src/actor.rs index 3da25ff..11d3c1a 100644 --- a/crates/open-mess-host/src/actor.rs +++ b/crates/open-mess-host/src/actor.rs @@ -20,8 +20,9 @@ use crate::sign_in::Caller; /// Requests waiting for a module beyond this are refused with backpressure on the sender. pub(crate) const QUEUE: usize = 32; -/// WebSocket frames and timer firings waiting for a module; a full queue holds the socket back. -/// With `MAX_WS_MESSAGE` frames that is at most 64 MiB held for a module outside its memory. +/// WebSocket frames, media batches and timer firings waiting for a module; a full queue holds the +/// socket or connection back. With `MAX_WS_MESSAGE` frames (or `MAX_RTC_QUEUE_BYTES` batches) that +/// is at most 64 MiB held for a module outside its memory. const EVENT_QUEUE: usize = 16; pub(crate) type Reply = oneshot::Sender>; @@ -31,6 +32,7 @@ pub(crate) enum Request { SignIn(Reply), Restore(String, Reply<()>), SignOut(String, Reply<()>), + EndSessions(String, Reply<()>), Call { account: String, tool: String, @@ -59,12 +61,29 @@ impl Request { match self { Request::SignIn(_) => Caller::SignIn, Request::PauseEvents(_) | Request::ResumeEvents(_) => Caller::Event, - Request::Restore(account, _) | Request::SignOut(account, _) | Request::Call { account, .. } => Caller::Account(account.clone()), + Request::Restore(account, _) | Request::SignOut(account, _) | Request::EndSessions(account, _) | Request::Call { account, .. } => { + Caller::Account(account.clone()) + } Request::GetAttachment { ids: [account, ..], .. } | Request::DescribeTarget { ids: [account, ..], .. } => { Caller::Account(account.clone()) } } } + + /// Nobody waits for the answer any more: the caller's limit passed while the request queued + /// behind others. Such a call is not run, so a write its caller reported as uncertain never goes + /// out late (spec 2026-10-29-live-captions, 4.1, R17). Ending the sessions and signing out still + /// run: they take back what other people see. + fn abandoned(&self) -> bool { + match self { + Request::SignIn(reply) => reply.is_closed(), + Request::Restore(_, reply) => reply.is_closed(), + Request::Call { reply, .. } => reply.is_closed(), + Request::GetAttachment { reply, .. } => reply.is_closed(), + Request::DescribeTarget { reply, .. } => reply.is_closed(), + Request::SignOut(..) | Request::EndSessions(..) | Request::PauseEvents(_) | Request::ResumeEvents(_) => false, + } + } } /// What a module instance's sockets and timers deliver to its `events` export, through the actor @@ -73,6 +92,8 @@ pub(crate) enum GuestEvent { WsMessage { conn: u32, text: String }, WsClosed { conn: u32, reason: String }, Timer { id: u32, generation: u64 }, + RtcData { conn: u32, messages: Vec>, dropped: u32 }, + RtcState { conn: u32, state: String }, } /// A task that belongs to a module instance (a socket, a timer): aborted when dropped, so an @@ -290,11 +311,20 @@ impl Actor { pub(crate) async fn run( &mut self, export: impl for<'a> FnOnce(&'a PlatformModule, &'a mut Store) -> Export<'a, R>, + ) -> Result { + self.run_for(self.limits.call_timeout, export).await + } + + /// [`Actor::run`] with a shorter limit than the call limit. + async fn run_for( + &mut self, + limit: Duration, + export: impl for<'a> FnOnce(&'a PlatformModule, &'a mut Store) -> Export<'a, R>, ) -> Result { if *self.state.borrow() == ModuleState::Disabled { return Err(HostError::Closed); } - self.deadline.set(Some(Instant::now() + self.limits.call_timeout)); + self.deadline.set(Some(Instant::now() + limit.min(self.limits.call_timeout))); let result = self.run_on_instance(export).await; self.deadline.set(None); match result { @@ -382,6 +412,21 @@ impl Actor { }) .await } + GuestEvent::RtcData { conn, messages, dropped } => { + self.run(move |m, s| Box::pin(async move { m.open_mess_module_events().call_on_rtc_data(s, conn, &messages, dropped).await })).await + } + GuestEvent::RtcState { conn, state } => { + self.run(move |m, s| { + Box::pin(async move { + // A failed or closed connection is gone; the module hears it once. + if state != "connected" { + s.data_mut().rtc.close(conn); + } + m.open_mess_module_events().call_on_rtc_state(s, conn, &state).await + }) + }) + .await + } GuestEvent::Timer { id, generation } => { self.run(move |m, s| { Box::pin(async move { @@ -397,6 +442,9 @@ impl Actor { } async fn answer(&mut self, request: Request) { + if request.abandoned() { + return; + } self.caller = request.caller(); match request { // The actor runs one request or event at a time, so answering proves no handler runs. @@ -427,6 +475,14 @@ impl Actor { let result = self.run(move |m, s| Box::pin(async move { m.open_mess_module_module().call_sign_out(s, &account).await })).await; reply.send(result).ok(); } + Request::EndSessions(account, reply) => { + let result = self + .run_for(limits::END_SESSIONS_TIMEOUT, move |m, s| { + Box::pin(async move { m.open_mess_module_module().call_end_sessions(s, &account).await }) + }) + .await; + reply.send(result).ok(); + } Request::Call { account, tool, args, reply } => { let result = self.run(move |m, s| Box::pin(async move { m.open_mess_module_module().call_call(s, &account, &tool, &args).await })).await; diff --git a/crates/open-mess-host/src/bindings.rs b/crates/open-mess-host/src/bindings.rs index 82573c9..d085b6d 100644 --- a/crates/open-mess-host/src/bindings.rs +++ b/crates/open-mess-host/src/bindings.rs @@ -8,7 +8,9 @@ use keyring_core::api::CredentialStore; use crate::error::ModuleError; use crate::limits; use crate::net; -use crate::runtime::{AccountInfo, Attachment, HostEvent, HostState, Manifest, NewMessage, Participant, SessionState, Target, ToolDecl, WamIdentity}; +use crate::runtime::{ + AccountInfo, Attachment, HostEvent, HostState, Manifest, MediaAccess, NewMessage, Participant, SessionState, Target, ToolDecl, WamIdentity, +}; use crate::storage::{Storage, StorageError}; wasmtime::component::bindgen!({ @@ -20,7 +22,7 @@ wasmtime::component::bindgen!({ exports: { default: async }, }); -use open_mess::module::{host, http, secrets, storage, types, websocket}; +use open_mess::module::{host, http, rtc, secrets, storage, types, websocket}; impl HostState { /// The module's storage, opened on first use. Not while the manifest is read at load: the @@ -77,6 +79,20 @@ impl websocket::Host for HostState { } } +impl rtc::Host for HostState { + async fn open(&mut self, label: String) -> Result<(u32, String), http::HttpError> { + Ok(self.rtc.open(self.env.media.as_ref(), &label, &self.guest_events).await?) + } + + async fn accept(&mut self, conn: u32, answer: String) -> Result<(), http::HttpError> { + Ok(self.rtc.accept(conn, &answer).await?) + } + + async fn close(&mut self, conn: u32) { + self.rtc.close(conn); + } +} + impl storage::Host for HostState { async fn get(&mut self, key: String) -> Option> { match self.storage().and_then(|storage| storage.get(&key)) { @@ -203,6 +219,7 @@ impl From for Manifest { .into_iter() .map(|t| ToolDecl { name: t.name, description: t.description, input_schema: t.input_schema, writes: t.writes }) .collect(), + media: m.media.map(|media| MediaAccess { peers: media.peers }), } } } diff --git a/crates/open-mess-host/src/ip_policy.rs b/crates/open-mess-host/src/ip_policy.rs index 0553109..09a0fe0 100644 --- a/crates/open-mess-host/src/ip_policy.rs +++ b/crates/open-mess-host/src/ip_policy.rs @@ -61,6 +61,22 @@ fn in_v6(ip: Ipv6Addr, net: Ipv6Addr, len: u32) -> bool { u128::from(ip) & mask == u128::from(net) } +/// Whether the range `net/len` holds any address [`is_allowed`] refuses under the strict policy, +/// so a manifest's media range cannot name a private or special network (spec +/// 2026-10-29-live-captions, 5.2). +pub(crate) fn range_has_special(net: IpAddr, len: u32) -> bool { + match net { + IpAddr::V4(v4) => V4_DENIED.iter().any(|&(denied, dlen)| in_v4(v4, denied, dlen.min(len)) || in_v4(denied, v4, len.min(dlen))), + IpAddr::V6(v6) => { + // Outside 2000::/3 (this covers mapped, compatible and NAT64 addresses), or touching + // one of its special parts. + len < 3 + || !in_v6(v6, GLOBAL_UNICAST, 3) + || V6_DENIED.iter().any(|&(denied, dlen)| in_v6(v6, denied, dlen.min(len)) || in_v6(denied, v6, len.min(dlen))) + } + } +} + fn is_global(ip: IpAddr) -> bool { match ip { IpAddr::V4(v4) => !V4_DENIED.iter().any(|&(net, len)| in_v4(v4, net, len)), @@ -128,6 +144,16 @@ mod tests { } } + #[test] + fn ranges_touching_special_networks_are_found() { + for (net, len) in [("10.0.0.0", 8), ("100.0.0.0", 8), ("192.0.0.0", 8), ("2000::", 3), ("2001:db8::", 32), ("fc00::", 16), ("::", 16)] { + assert!(range_has_special(net.parse().unwrap(), len), "{net}/{len}"); + } + for (net, len) in [("52.112.0.0", 14), ("8.0.0.0", 8), ("2603:1000::", 25)] { + assert!(!range_has_special(net.parse().unwrap(), len), "{net}/{len}"); + } + } + #[test] fn test_policy_adds_loopback_only() { assert!(is_allowed("127.0.0.1".parse().unwrap(), AddressPolicy::AllowLoopbackForTests)); diff --git a/crates/open-mess-host/src/lib.rs b/crates/open-mess-host/src/lib.rs index 68f12e9..409b17c 100644 --- a/crates/open-mess-host/src/lib.rs +++ b/crates/open-mess-host/src/lib.rs @@ -8,6 +8,8 @@ pub mod hosts; pub mod ip_policy; pub mod limits; mod net; +mod proxy; +mod rtc; mod runtime; mod secrets; mod sign_in; @@ -20,8 +22,8 @@ pub use actor::ModuleState; pub use error::{HostError, ModuleError}; pub use net::{HttpError, Net, Request, Response}; pub use runtime::{ - AccountInfo, Attachment, Engine, HostEvent, Manifest, ModuleContext, ModuleHandle, NewMessage, Participant, SessionState, Target, ToolDecl, - WamIdentity, + AccountInfo, Attachment, Engine, HostEvent, Manifest, MediaAccess, ModuleContext, ModuleHandle, NewMessage, Participant, SessionState, Target, + ToolDecl, WamIdentity, }; pub use secrets::{delete_account_secrets, platform_store as credential_store}; #[cfg(any(test, feature = "test-support"))] diff --git a/crates/open-mess-host/src/limits.rs b/crates/open-mess-host/src/limits.rs index 0aa6d1d..c59b4d6 100644 --- a/crates/open-mess-host/src/limits.rs +++ b/crates/open-mess-host/src/limits.rs @@ -5,6 +5,9 @@ use std::time::Duration; /// Wall-clock limit of one call into a module, time spent in host imports included. pub const CALL_TIMEOUT: Duration = Duration::from_secs(30); +/// The `end-sessions` call: the daemon waits on it before it stops (spec 2026-10-29-live-captions, +/// 4.3). +pub const END_SESSIONS_TIMEOUT: Duration = Duration::from_secs(5); /// Linear memory of one module instance, all its memories together. pub const MAX_MEMORY: usize = 256 * 1024 * 1024; /// Table elements of one module instance, all its tables together. @@ -32,6 +35,27 @@ pub const READ_TIMEOUT: Duration = Duration::from_secs(30); pub const MAX_WS_MESSAGE: usize = 4 * 1024 * 1024; /// Open WebSockets per module instance. pub const MAX_SOCKETS: usize = 4; +/// Open media (`rtc`) connections per module instance (spec 2026-10-29-live-captions, 5.2). +pub const MAX_RTC: usize = 4; +/// A data channel message to a module; a larger one is dropped and counted. +pub const MAX_RTC_MESSAGE: usize = 64 * 1024; +/// Data channel messages and bytes one connection may pass to its module each second; the rest is +/// dropped and counted. +pub const MAX_RTC_MESSAGES_PER_SECOND: usize = 50; +pub const MAX_RTC_BYTES_PER_SECOND: usize = 256 * 1024; +/// Data channel messages waiting for a busy module, per connection; beyond either bound the +/// newest are dropped and counted. +pub const MAX_RTC_QUEUE: usize = 2000; +pub const MAX_RTC_QUEUE_BYTES: usize = 4 * 1024 * 1024; +/// A media connection is closed this long after it was opened. +pub const RTC_LIFETIME: Duration = Duration::from_secs(4 * 3600); +/// How long `rtc.open` waits for the local candidates. +pub const RTC_GATHER_TIMEOUT: Duration = Duration::from_secs(5); +/// An SDP answer given to `rtc.accept`, and a data channel label. +pub const MAX_SDP: usize = 64 * 1024; +pub const MAX_RTC_LABEL: usize = 64; +/// Address ranges in a manifest's `media`. +pub const MAX_MEDIA_RANGES: usize = 32; /// Pending timers per module instance. pub const MAX_TIMERS: usize = 32; /// The shortest timer delay; shorter ones are lengthened to it. diff --git a/crates/open-mess-host/src/net.rs b/crates/open-mess-host/src/net.rs index 4454293..0411326 100644 --- a/crates/open-mess-host/src/net.rs +++ b/crates/open-mess-host/src/net.rs @@ -115,6 +115,9 @@ pub struct Net { /// Test support: the loopback server that serves every allowed host over plain HTTP. #[cfg(any(test, feature = "test-support"))] fake_server: Option, + /// Test support: the loopback server that takes every module WebSocket, over plain ws. + #[cfg(any(test, feature = "test-support"))] + fake_sockets: Option, } impl Net { @@ -137,19 +140,38 @@ impl Net { limits, #[cfg(any(test, feature = "test-support"))] fake_server: None, + #[cfg(any(test, feature = "test-support"))] + fake_sockets: None, } } + pub(crate) fn policy(&self) -> AddressPolicy { + self.policy + } + /// Test support: every host on a module's list is served by the loopback server at `addr`, /// over plain HTTP, with the request's own host in its `Host` header. The host list is still /// checked, and nothing is resolved, so a module under test never reaches the real host. - /// HTTP only; WebSockets are not redirected. + /// HTTP only; [`Net::serving_websockets_from`] does the same for WebSockets. #[cfg(any(test, feature = "test-support"))] pub fn serving_hosts_from(mut self, addr: SocketAddr) -> Net { self.fake_server = Some(addr); self } + /// Test support: every WebSocket to a host on a module's list goes to the loopback server at + /// `addr`, over plain ws, with the URL's own host in `Host` and its path and query unchanged. + #[cfg(any(test, feature = "test-support"))] + pub fn serving_websockets_from(mut self, addr: SocketAddr) -> Net { + self.fake_sockets = Some(addr); + self + } + + #[cfg(any(test, feature = "test-support"))] + pub(crate) fn fake_sockets(&self) -> Option { + self.fake_sockets + } + /// Checks the URL's host against the module's list, resolves it once and checks every /// address; connections go only to the addresses returned. pub(crate) async fn resolve(&self, hosts: &HostList, url: &url::Url) -> Result, HttpError> { diff --git a/crates/open-mess-host/src/proxy.rs b/crates/open-mess-host/src/proxy.rs new file mode 100644 index 0000000..08333da --- /dev/null +++ b/crates/open-mess-host/src/proxy.rs @@ -0,0 +1,178 @@ +//! The system proxy, for media connections (spec 2026-10-29-live-captions, 5.2). WebRTC cannot go +//! through an HTTP proxy, and a direct packet against the proxy rules would show the network the +//! user's address, so `rtc` sends nothing to an address the system would proxy. The answer comes +//! from WinHTTP, as for the Windows client: a PAC script (`AutoConfigURL`), WPAD auto-detection +//! and the static settings. + +use std::net::SocketAddr; + +use hyper_util::client::proxy::matcher::Matcher; + +use crate::net::HttpError; + +/// What proxy auto-configuration said for one address. Only Windows has auto-configuration to ask. +#[cfg_attr(not(windows), allow(dead_code))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Auto { + Direct, + Proxy, + /// Auto-detection found no script: a network without WPAD, the default Windows setup. + NoScript, + /// A script exists but could not be fetched or run. + Failed, +} + +/// Whether traffic to an address goes directly. A script that cannot be run fails closed; no +/// script falls back to the static settings, as Windows does. +fn goes_direct(auto: Option, static_proxy: bool) -> bool { + match auto { + Some(Auto::Direct) => true, + Some(Auto::Proxy | Auto::Failed) => false, + Some(Auto::NoScript) | None => !static_proxy, + } +} + +/// What proxy settings that cannot be read mean: none set up when Windows has no settings at all; +/// any other error fails closed, since a PAC script it hides may require a proxy (Codex review of +/// b21b25d). +#[cfg_attr(not(windows), allow(dead_code))] +fn unreadable(no_settings: bool) -> Option { + if no_settings { None } else { Some(Auto::Failed) } +} + +/// The address as a URL, the way the proxy rules are asked about it. +fn url(addr: SocketAddr) -> String { + format!("https://{addr}/") +} + +fn static_proxy(addr: SocketAddr) -> bool { + url(addr).parse().is_ok_and(|uri| Matcher::from_system().intercept(&uri).is_some()) +} + +/// The addresses of `targets` that the system sends directly, not through a proxy. +pub(crate) async fn direct(targets: Vec) -> Result, HttpError> { + tokio::task::spawn_blocking(move || { + let auto = auto_config(&targets); + targets.iter().zip(auto).filter(|(addr, auto)| goes_direct(*auto, static_proxy(**addr))).map(|(addr, _)| *addr).collect() + }) + .await + .map_err(|e| HttpError::Network(format!("proxy lookup: {e}"))) +} + +#[cfg(not(windows))] +fn auto_config(targets: &[SocketAddr]) -> Vec> { + vec![None; targets.len()] +} + +/// WinHTTP's auto-configuration answer for each target; `None` where the user has none set up. +#[cfg(windows)] +fn auto_config(targets: &[SocketAddr]) -> Vec> { + winhttp::auto_config(targets) +} + +#[cfg(windows)] +mod winhttp { + use std::net::SocketAddr; + + use windows::Win32::Foundation::{ERROR_FILE_NOT_FOUND, GlobalFree, HGLOBAL}; + use windows::Win32::Networking::WinHttp::{ + ERROR_WINHTTP_AUTODETECTION_FAILED, WINHTTP_ACCESS_TYPE_NO_PROXY, WINHTTP_AUTO_DETECT_TYPE_DHCP, WINHTTP_AUTO_DETECT_TYPE_DNS_A, + WINHTTP_AUTOPROXY_AUTO_DETECT, WINHTTP_AUTOPROXY_CONFIG_URL, WINHTTP_AUTOPROXY_OPTIONS, WINHTTP_CURRENT_USER_IE_PROXY_CONFIG, + WINHTTP_PROXY_INFO, WinHttpCloseHandle, WinHttpGetIEProxyConfigForCurrentUser, WinHttpGetProxyForUrl, WinHttpOpen, + }; + use windows::core::{BOOL, HRESULT, HSTRING, PCWSTR, PWSTR}; + + use super::{Auto, unreadable, url}; + + /// Frees a string WinHTTP allocated for the caller, which WinHTTP documents is freed with + /// `GlobalFree` (a Windows API binding with no safe wrapper, AGENTS.md "Never crash"). + #[allow(unsafe_code)] + fn free(text: PWSTR) { + if !text.is_null() { + unsafe { GlobalFree(Some(HGLOBAL(text.0.cast()))) }.ok(); + } + } + + // Windows API bindings with no safe wrapper (AGENTS.md, "Never crash"): the user's proxy + // settings, a WinHTTP session and `WinHttpGetProxyForUrl`, which runs the PAC script or WPAD + // the way the Windows client does. Every string WinHTTP hands back is freed above; the session + // handle is closed before returning. + #[allow(unsafe_code)] + pub(super) fn auto_config(targets: &[SocketAddr]) -> Vec> { + let mut settings = WINHTTP_CURRENT_USER_IE_PROXY_CONFIG::default(); + let read = unsafe { WinHttpGetIEProxyConfigForCurrentUser(&mut settings) }; + let config_url = settings.lpszAutoConfigUrl; + let detect = settings.fAutoDetect.as_bool(); + free(settings.lpszProxy); + free(settings.lpszProxyBypass); + if let Err(e) = read { + free(config_url); + return vec![unreadable(e.code() == ERROR_FILE_NOT_FOUND.to_hresult()); targets.len()]; + } + if !detect && config_url.is_null() { + return vec![None; targets.len()]; + } + let agent = HSTRING::from("open_mess"); + let session = unsafe { WinHttpOpen(&agent, WINHTTP_ACCESS_TYPE_NO_PROXY, PCWSTR::null(), PCWSTR::null(), 0) }; + if session.is_null() { + free(config_url); + return vec![Some(Auto::Failed); targets.len()]; + } + let mut options = WINHTTP_AUTOPROXY_OPTIONS { + dwFlags: if config_url.is_null() { 0 } else { WINHTTP_AUTOPROXY_CONFIG_URL } | if detect { WINHTTP_AUTOPROXY_AUTO_DETECT } else { 0 }, + dwAutoDetectFlags: if detect { WINHTTP_AUTO_DETECT_TYPE_DHCP | WINHTTP_AUTO_DETECT_TYPE_DNS_A } else { 0 }, + lpszAutoConfigUrl: PCWSTR(config_url.0), + fAutoLogonIfChallenged: BOOL::from(true), + ..Default::default() + }; + let answers = targets + .iter() + .map(|addr| { + let target = HSTRING::from(url(*addr)); + let mut info = WINHTTP_PROXY_INFO::default(); + let answer = match unsafe { WinHttpGetProxyForUrl(session, &target, &mut options, &mut info) } { + Ok(()) if info.dwAccessType == WINHTTP_ACCESS_TYPE_NO_PROXY || info.lpszProxy.is_null() => Auto::Direct, + Ok(()) => Auto::Proxy, + // Only auto-detection that found nothing means "no script"; a configured + // script that failed does not. + Err(e) if config_url.is_null() && e.code() == HRESULT::from_win32(ERROR_WINHTTP_AUTODETECTION_FAILED) => Auto::NoScript, + Err(_) => Auto::Failed, + }; + free(info.lpszProxy); + free(info.lpszProxyBypass); + Some(answer) + }) + .collect(); + unsafe { WinHttpCloseHandle(session) }.ok(); + free(config_url); + answers + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_script_that_cannot_run_refuses_and_a_missing_one_falls_back() { + assert!(goes_direct(Some(Auto::Direct), true)); + assert!(!goes_direct(Some(Auto::Proxy), false)); + assert!(!goes_direct(Some(Auto::Failed), false)); + assert!(goes_direct(Some(Auto::NoScript), false)); + assert!(!goes_direct(Some(Auto::NoScript), true)); + assert!(goes_direct(None, false)); + assert!(!goes_direct(None, true)); + } + + #[test] + fn settings_that_cannot_be_read_refuse_unless_there_are_none() { + assert_eq!(unreadable(true), None); + assert!(!goes_direct(unreadable(false), false)); + } + + #[test] + fn addresses_are_asked_about_as_https_urls() { + assert_eq!(url("52.113.1.2:3478".parse().unwrap()), "https://52.113.1.2:3478/"); + assert_eq!(url("[2603:1063::1]:3478".parse().unwrap()), "https://[2603:1063::1]:3478/"); + } +} diff --git a/crates/open-mess-host/src/rtc.rs b/crates/open-mess-host/src/rtc.rs new file mode 100644 index 0000000..32aebee --- /dev/null +++ b/crates/open-mess-host/src/rtc.rs @@ -0,0 +1,583 @@ +//! Module media connections (spec 2026-10-29-live-captions, 5.2): a WebRTC peer connection with +//! one data channel and nothing else, for a module whose manifest names its media peers. No audio +//! or video section is ever offered or accepted, so no RTP goes either way, and every packet +//! passes a socket that drops what goes to or comes from an address outside the manifest's +//! ranges. Data channel messages reach the module in batches through its `events` export. + +use std::collections::{HashMap, VecDeque}; +use std::future::Future; +use std::io::{self, IoSliceMut}; +use std::net::{IpAddr, SocketAddr}; +use std::pin::Pin; +use std::sync::{Arc, Mutex, PoisonError}; +use std::task::{Context, Poll}; +use std::time::Duration; + +use tokio::sync::mpsc; +use tokio::time::Instant; +use webrtc::data_channel::{DataChannel, DataChannelEvent}; +use webrtc::peer_connection::{ + MediaEngine, PeerConnection, PeerConnectionBuilder, PeerConnectionEventHandler, RTCBundlePolicy, RTCConfigurationBuilder, RTCIceGatheringState, + RTCPeerConnectionState, RTCRtcpMuxPolicy, RTCSessionDescription, SettingEngineBuilder, +}; +use webrtc::runtime::{AsyncInterval, AsyncTcpListener, AsyncTcpStream, AsyncUdpSocket, JoinHandle, RecvMeta, Runtime, TokioRuntime, Transmit}; + +use crate::actor::{GuestEvent, Task}; +use crate::error::HostError; +use crate::ip_policy::{self, AddressPolicy}; +use crate::limits; +use crate::net::{HttpError, blocked}; + +fn network(e: impl ToString) -> HttpError { + HttpError::Network(e.to_string()) +} + +/// The address ranges of a manifest's `media` (spec 5.2), each network address masked. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct Peers(Vec<(IpAddr, u32)>); + +impl Peers { + /// CIDR ranges: at most `MAX_MEDIA_RANGES`, a prefix of at least /8 (IPv4) or /16 (IPv6), none + /// touching a private or special network. + pub(crate) fn parse(ranges: &[String]) -> Result { + if ranges.is_empty() || ranges.len() > limits::MAX_MEDIA_RANGES { + return Err(HostError::Load(format!("media access needs 1 to {} address ranges", limits::MAX_MEDIA_RANGES))); + } + let mut peers = Vec::new(); + for range in ranges { + let refuse = |why: &str| HostError::Load(format!("media range {range:?}: {why}")); + let (addr, len) = range.split_once('/').ok_or_else(|| refuse("not a CIDR range"))?; + let addr: IpAddr = addr.parse().map_err(|_| refuse("not an IP address"))?; + let len: u32 = len.parse().map_err(|_| refuse("not a prefix length"))?; + let (min, max) = if addr.is_ipv4() { (8, 32) } else { (16, 128) }; + if !(min..=max).contains(&len) { + return Err(refuse(&format!("the prefix must be /{min} to /{max}"))); + } + let net = mask(addr, len); + if ip_policy::range_has_special(net, len) { + return Err(refuse("it holds private or special addresses")); + } + peers.push((net, len)); + } + Ok(Peers(peers)) + } + + fn contains(&self, ip: IpAddr) -> bool { + let ip = plain(ip); + self.0.iter().any(|&(net, len)| mask(ip, len) == net && ip.is_ipv4() == net.is_ipv4()) + } +} + +/// An IPv4 address mapped into IPv6 (as a dual-stack socket reports it) as plain IPv4. +fn plain(ip: IpAddr) -> IpAddr { + match ip { + IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(ip, IpAddr::V4), + v4 => v4, + } +} + +fn mask(ip: IpAddr, len: u32) -> IpAddr { + match ip { + IpAddr::V4(v4) => IpAddr::V4((u32::from(v4) & u32::MAX.checked_shl(32 - len.min(32)).unwrap_or(0)).into()), + IpAddr::V6(v6) => IpAddr::V6((u128::from(v6) & u128::MAX.checked_shl(128 - len.min(128)).unwrap_or(0)).into()), + } +} + +/// Who a module's media connections may exchange packets with: an address in the manifest's +/// ranges that the address policy allows, and loopback under the tests' policy. Once a connection +/// has its answer, only the answer's candidates (address and port) that the system proxy leaves +/// direct: a peer-reflexive address or port in the ranges was never asked about the proxy. +#[derive(Clone, Debug)] +pub(crate) struct Allowed { + peers: Peers, + policy: AddressPolicy, + /// Set by `accept`; shared with the connection's sockets. + direct: Arc>>>, +} + +impl Allowed { + pub(crate) fn new(peers: Peers, policy: AddressPolicy) -> Allowed { + Allowed { peers, policy, direct: Arc::default() } + } + + /// The same ranges with a pinned set of its own, for one connection. + fn for_connection(&self) -> Allowed { + Allowed { direct: Arc::default(), ..self.clone() } + } + + fn pin(&self, direct: &[SocketAddr]) { + let direct = direct.iter().map(|addr| SocketAddr::new(plain(addr.ip()), addr.port())).collect(); + *self.direct.lock().unwrap_or_else(PoisonError::into_inner) = Some(direct); + } + + /// Whether a packet may go to or come from `addr`. + fn allows(&self, addr: SocketAddr) -> bool { + if let Some(direct) = &*self.direct.lock().unwrap_or_else(PoisonError::into_inner) + && !direct.contains(&SocketAddr::new(plain(addr.ip()), addr.port())) + { + return false; + } + self.in_ranges(addr.ip()) + } + + /// Whether `ip` is in the manifest's ranges and allowed by the address policy. + fn in_ranges(&self, ip: IpAddr) -> bool { + #[cfg(any(test, feature = "test-support"))] + if self.policy == AddressPolicy::AllowLoopbackForTests && ip.is_loopback() { + return true; + } + self.peers.contains(ip) && ip_policy::is_allowed(ip, self.policy) + } + + /// The local addresses to bind: every interface (the library skips loopback and link-local), + /// or loopback alone in tests. ponytail: IPv4 only; add `[::]:0` once a media peer needs IPv6. + fn bind(&self) -> String { + #[cfg(any(test, feature = "test-support"))] + if self.policy == AddressPolicy::AllowLoopbackForTests { + return "127.0.0.1:0".to_string(); + } + "0.0.0.0:0".to_string() + } +} + +/// A UDP socket that sends only to allowed addresses and drops datagrams from any other, so +/// neither a candidate nor a peer-reflexive address outside the ranges ever carries traffic. +#[derive(Debug)] +struct PolicedUdp { + inner: Arc, + allowed: Allowed, +} + +impl AsyncUdpSocket for PolicedUdp { + fn local_addr(&self) -> io::Result { + self.inner.local_addr() + } + + fn poll_send(&self, cx: &mut Context<'_>, transmit: &Transmit<'_>) -> Poll> { + if !self.allowed.allows(transmit.destination) { + // Reported as sent: an error could make the library give the socket up. + return Poll::Ready(Ok(transmit.contents.len())); + } + self.inner.poll_send(cx, transmit) + } + + fn poll_recv(&self, cx: &mut Context<'_>, bufs: &mut [IoSliceMut<'_>], meta: &mut [RecvMeta]) -> Poll> { + // One datagram at a time (no GRO), so a refused one is simply read past. + let (Some(buf), Some(meta)) = (bufs.get_mut(..1), meta.get_mut(..1)) else { return Poll::Ready(Ok(0)) }; + loop { + match self.inner.poll_recv(cx, buf, meta) { + Poll::Ready(Ok(n)) if n > 0 && meta.first().is_some_and(|m| !self.allowed.allows(m.addr)) => continue, + other => return other, + } + } + } +} + +/// The tokio runtime with its sockets policed: UDP through [`PolicedUdp`], no TCP (ICE over TCP is +/// not used) and no name lookups (there is no STUN or TURN server to look up). +#[derive(Debug)] +struct Policed { + inner: TokioRuntime, + allowed: Allowed, +} + +fn refused(what: &str) -> io::Result { + Err(io::Error::new(io::ErrorKind::PermissionDenied, format!("{what} is not allowed for module media"))) +} + +impl Runtime for Policed { + fn spawn(&self, future: Pin + Send>>) -> Box { + self.inner.spawn(future) + } + + fn wrap_udp_socket(&self, socket: std::net::UdpSocket) -> io::Result> { + let inner = self.inner.wrap_udp_socket(socket)?; + Ok(Arc::new(PolicedUdp { inner, allowed: self.allowed.clone() })) + } + + fn wrap_tcp_listener(&self, _listener: std::net::TcpListener) -> io::Result> { + refused("a TCP listener") + } + + fn connect_tcp<'a>(&'a self, _remote_addr: SocketAddr) -> Pin>> + Send + 'a>> { + Box::pin(async { refused("a TCP connection") }) + } + + fn resolve_host<'a>(&'a self, _host: &'a str) -> Pin>> + Send + 'a>> { + Box::pin(async { refused("a name lookup") }) + } + + fn now(&self) -> std::time::Instant { + self.inner.now() + } + + fn sleep(&self, duration: Duration) -> Pin + Send + 'static>> { + self.inner.sleep(duration) + } + + fn interval(&self, period: Duration) -> Box { + self.inner.interval(period) + } + + fn block_on(&self, future: Pin + '_>>) { + self.inner.block_on(future) + } + + fn name(&self) -> &'static str { + "open_mess" + } +} + +/// Passes the connection's state changes and the end of candidate gathering on. +struct Handler { + states: mpsc::Sender, + gathered: mpsc::Sender<()>, +} + +#[async_trait::async_trait] +impl PeerConnectionEventHandler for Handler { + async fn on_ice_gathering_state_change(&self, state: RTCIceGatheringState) { + if state == RTCIceGatheringState::Complete { + self.gathered.try_send(()).ok(); + } + } + + async fn on_connection_state_change(&self, state: RTCPeerConnectionState) { + self.states.try_send(state).ok(); + } +} + +/// The checked answer: only its data section, only the candidates in the module's ranges. +fn check_answer(answer: &str, allowed: &Allowed) -> Result<(Vec, Vec), HttpError> { + let mut lines = Vec::new(); + let mut candidates = Vec::new(); + let mut sections = 0; + for line in answer.lines() { + let line = line.trim_end_matches('\r'); + if let Some(media) = line.strip_prefix("m=") { + sections += 1; + if sections > 1 || !(media.starts_with("application ") && media.contains("DTLS/SCTP")) { + return Err(blocked("the answer has a section other than the data channel")); + } + } + if let Some(candidate) = line.strip_prefix("a=candidate:") { + // foundation component transport priority address port "typ" type ... + let fields: Vec<&str> = candidate.split_whitespace().collect(); + let addr = fields.get(4).and_then(|a| a.parse::().ok()).zip(fields.get(5).and_then(|p| p.parse::().ok())); + match addr { + Some((ip, port)) if allowed.in_ranges(ip) => candidates.push(SocketAddr::new(ip, port)), + // A name (mDNS), a malformed line or an address outside the ranges. + _ => continue, + } + } + lines.push(line.to_string()); + } + if sections == 0 { + return Err(blocked("the answer has no data channel section")); + } + if candidates.is_empty() { + return Err(blocked("no candidate of the answer is in the module's media ranges")); + } + Ok((lines, candidates)) +} + +/// The answer without the candidates the system proxy covers, and the addresses left; refused when +/// none is left. +async fn without_proxied(lines: Vec, candidates: Vec) -> Result<(String, Vec), HttpError> { + let direct = crate::proxy::direct(candidates).await?; + if direct.is_empty() { + return Err(blocked("proxy: your network sends traffic through a proxy; media needs a direct connection")); + } + let kept: Vec = lines + .into_iter() + .filter(|line| { + let Some(candidate) = line.strip_prefix("a=candidate:") else { return true }; + let fields: Vec<&str> = candidate.split_whitespace().collect(); + let addr = fields.get(4).and_then(|a| a.parse::().ok()).zip(fields.get(5).and_then(|p| p.parse::().ok())); + addr.is_some_and(|(ip, port)| direct.contains(&SocketAddr::new(ip, port))) + }) + .collect(); + Ok((kept.join("\r\n") + "\r\n", direct)) +} + +/// Data channel messages waiting for the module, within the per-connection limits. +#[derive(Default)] +struct Pending { + messages: VecDeque>, + bytes: usize, + dropped: u32, + window: Option, + window_messages: usize, + window_bytes: usize, +} + +impl Pending { + fn push(&mut self, data: Vec) { + let now = Instant::now(); + if self.window.is_none_or(|start| now.duration_since(start) >= Duration::from_secs(1)) { + (self.window, self.window_messages, self.window_bytes) = (Some(now), 0, 0); + } + let len = data.len(); + let over = len > limits::MAX_RTC_MESSAGE + || self.window_messages >= limits::MAX_RTC_MESSAGES_PER_SECOND + || self.window_bytes.saturating_add(len) > limits::MAX_RTC_BYTES_PER_SECOND + || self.messages.len() >= limits::MAX_RTC_QUEUE + || self.bytes.saturating_add(len) > limits::MAX_RTC_QUEUE_BYTES; + if over { + self.dropped = self.dropped.saturating_add(1); + return; + } + self.window_messages += 1; + self.window_bytes += len; + self.bytes += len; + self.messages.push_back(data); + } + + fn take(&mut self, conn: u32) -> Option { + if self.messages.is_empty() && self.dropped == 0 { + return None; + } + self.bytes = 0; + let messages = std::mem::take(&mut self.messages).into(); + Some(GuestEvent::RtcData { conn, messages, dropped: std::mem::take(&mut self.dropped) }) + } +} + +/// Moves one connection's data channel messages and state to the module until the connection +/// fails, closes or reaches its lifetime. +async fn serve(conn: u32, channel: Arc, mut states: mpsc::Receiver, events: mpsc::Sender) { + let mut pending = Pending::default(); + // A state for the module after the messages before it; the last one ends the loop. + let mut state: Option<&'static str> = None; + let mut ending = false; + let end = tokio::time::sleep(limits::RTC_LIFETIME); + tokio::pin!(end); + loop { + let ready = !pending.messages.is_empty() || pending.dropped > 0 || state.is_some(); + tokio::select! { + permit = events.reserve(), if ready => { + let Ok(permit) = permit else { return }; + match pending.take(conn) { + Some(event) => permit.send(event), + None => { + if let Some(state) = state.take() { + permit.send(GuestEvent::RtcState { conn, state: state.to_string() }); + } + if ending { + return; + } + } + } + } + event = channel.poll(), if !ending => match event { + Some(DataChannelEvent::OnMessage(message)) => pending.push(message.data.to_vec()), + Some(DataChannelEvent::OnClose) | None => (state, ending) = (Some("closed"), true), + Some(_) => {} + }, + changed = states.recv(), if !ending => match changed { + Some(RTCPeerConnectionState::Connected) => state = Some("connected"), + Some(RTCPeerConnectionState::Failed) => (state, ending) = (Some("failed"), true), + Some(RTCPeerConnectionState::Closed) | None => (state, ending) = (Some("closed"), true), + Some(_) => {} + }, + () = &mut end, if !ending => (state, ending) = (Some("closed"), true), + else => return, + } + } +} + +struct Conn { + pc: Arc, + allowed: Allowed, + answered: bool, + _task: Task, +} + +impl Drop for Conn { + fn drop(&mut self) { + // Ends the library's own tasks and sockets; outside a runtime there is nothing to end. + let pc = self.pc.clone(); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + pc.close().await.ok(); + }); + } + } +} + +/// One module instance's media connections; dropping it closes them. +#[derive(Default)] +pub(crate) struct Connections { + open: HashMap, + last: u32, +} + +impl Connections { + pub(crate) async fn open( + &mut self, + allowed: Option<&Allowed>, + label: &str, + events: &mpsc::Sender, + ) -> Result<(u32, String), HttpError> { + let allowed = allowed.ok_or_else(|| blocked("the module's manifest has no media access"))?.for_connection(); + if self.open.len() >= limits::MAX_RTC { + return Err(blocked(format!("at most {} open media connections", limits::MAX_RTC))); + } + if label.is_empty() || label.len() > limits::MAX_RTC_LABEL { + return Err(blocked(format!("the label must be 1 to {} bytes", limits::MAX_RTC_LABEL))); + } + let (state_tx, states) = mpsc::channel(8); + let (gathered_tx, mut gathered) = mpsc::channel(1); + #[cfg_attr(not(any(test, feature = "test-support")), allow(unused_mut))] + let mut settings = SettingEngineBuilder::new(); + #[cfg(any(test, feature = "test-support"))] + if allowed.policy == AddressPolicy::AllowLoopbackForTests { + settings = settings.with_include_loopback_candidate(true); + } + let configuration = + RTCConfigurationBuilder::new().with_bundle_policy(RTCBundlePolicy::MaxBundle).with_rtcp_mux_policy(RTCRtcpMuxPolicy::Require).build(); + let pc = PeerConnectionBuilder::new() + .with_configuration(configuration) + .with_media_engine(MediaEngine::default()) + .with_setting_engine(settings.build()) + .with_handler(Arc::new(Handler { states: state_tx, gathered: gathered_tx })) + .with_runtime(Arc::new(Policed { inner: TokioRuntime, allowed: allowed.clone() })) + .with_udp_addrs(vec![allowed.bind()]) + .build() + .await + .map_err(network)?; + let pc: Arc = Arc::new(pc); + let opened = async { + let channel = pc.create_data_channel(label, None).await.map_err(network)?; + let offer = pc.create_offer(None).await.map_err(network)?; + pc.set_local_description(offer).await.map_err(network)?; + tokio::time::timeout(limits::RTC_GATHER_TIMEOUT, gathered.recv()).await.ok(); + let offer = pc.local_description().await.ok_or_else(|| network("no local description"))?; + Ok::<_, HttpError>((channel, offer.sdp)) + }; + let (channel, sdp) = match opened.await { + Ok(opened) => opened, + Err(e) => { + pc.close().await.ok(); + return Err(e); + } + }; + self.last = self.last.wrapping_add(1); + let conn = self.last; + let task = Task(tokio::spawn(serve(conn, channel, states, events.clone()))); + self.open.insert(conn, Conn { pc, allowed, answered: false, _task: task }); + Ok((conn, sdp)) + } + + pub(crate) async fn accept(&mut self, conn: u32, answer: &str) -> Result<(), HttpError> { + let open = self.open.get_mut(&conn).ok_or_else(|| network("no such media connection"))?; + if open.answered { + return Err(blocked("the connection has its answer already")); + } + if answer.len() > limits::MAX_SDP { + return Err(HttpError::TooLarge); + } + let (lines, candidates) = check_answer(answer, &open.allowed)?; + let (sdp, direct) = without_proxied(lines, candidates).await?; + let answer = RTCSessionDescription::answer(sdp).map_err(network)?; + // Before the answer nothing reaches a far end: ICE has no remote candidate to check. + open.allowed.pin(&direct); + open.pc.set_remote_description(answer).await.map_err(network)?; + open.answered = true; + Ok(()) + } + + /// Drops a connection: one the module closes, or one that failed or ended. + pub(crate) fn close(&mut self, conn: u32) { + self.open.remove(&conn); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn allowed(ranges: &[&str]) -> Allowed { + Allowed::new(Peers::parse(&ranges.iter().map(|r| r.to_string()).collect::>()).unwrap(), AddressPolicy::Strict) + } + + #[test] + fn media_ranges_are_checked() { + assert!(Peers::parse(&["52.112.0.0/14".to_string(), "2603:1063::/38".to_string()]).is_ok()); + for bad in ["52.112.0.0", "52.112.0.0/7", "0.0.0.0/0", "10.0.0.0/8", "192.168.1.0/24", "2603::/15", "fe80::/64", "x/8", "52.112.0.0/33"] { + assert!(Peers::parse(&[bad.to_string()]).is_err(), "{bad}"); + } + assert!(Peers::parse(&[]).is_err()); + assert!(Peers::parse(&vec!["52.112.0.0/14".to_string(); limits::MAX_MEDIA_RANGES + 1]).is_err()); + } + + #[test] + fn only_addresses_in_the_ranges_are_allowed() { + let allowed = allowed(&["52.112.0.0/14"]); + assert!(allowed.allows("52.113.1.2:3478".parse().unwrap())); + assert!(allowed.allows("[::ffff:52.113.1.2]:3478".parse().unwrap())); + for other in ["52.116.0.1:3478", "8.8.8.8:3478", "127.0.0.1:3478", "10.0.0.1:3478"] { + assert!(!allowed.allows(other.parse().unwrap()), "{other}"); + } + } + + const ANSWER: &str = "v=0\r\no=- 1 2 IN IP4 0.0.0.0\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\n\ + m=application 3478 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 52.113.1.2\r\na=mid:0\r\n\ + a=candidate:1 1 UDP 2130706431 52.113.1.2 3478 typ relay\r\n\ + a=candidate:2 1 UDP 2130706431 8.8.8.8 3478 typ relay\r\n\ + a=candidate:3 1 UDP 2130706431 host.local 3478 typ host\r\n\ + a=candidate:4 1 UDP 2130706431 10.0.0.1 3478 typ host\r\na=sctp-port:5000\r\n"; + + #[test] + fn after_the_answer_only_its_direct_candidates_are_reachable() { + let allowed = allowed(&["52.112.0.0/14"]).for_connection(); + assert!(allowed.allows("52.113.1.3:3478".parse().unwrap())); + allowed.pin(&["52.113.1.2:3478".parse().unwrap()]); + assert!(allowed.allows("52.113.1.2:3478".parse().unwrap())); + assert!(allowed.allows("[::ffff:52.113.1.2]:3478".parse().unwrap())); + // In the ranges, but never asked about the proxy: a peer-reflexive address, or another port + // of the candidate's address that a PAC script may send through a proxy. + assert!(!allowed.allows("52.113.1.3:3478".parse().unwrap())); + assert!(!allowed.allows("52.113.1.2:5000".parse().unwrap())); + // Another connection of the module keeps its own set. + assert!(allowed.for_connection().allows("52.113.1.3:3478".parse().unwrap())); + } + + #[test] + fn the_answer_keeps_only_candidates_in_the_ranges() { + let (lines, candidates) = check_answer(ANSWER, &allowed(&["52.112.0.0/14"])).unwrap(); + assert_eq!(candidates, ["52.113.1.2:3478".parse::().unwrap()]); + assert_eq!(lines.iter().filter(|l| l.starts_with("a=candidate:")).count(), 1); + assert!(lines.iter().any(|l| l == "a=sctp-port:5000")); + } + + #[test] + fn an_answer_with_media_or_no_allowed_candidate_is_refused() { + let audio = ANSWER.replace("a=sctp-port:5000\r\n", "a=sctp-port:5000\r\nm=audio 3478 UDP/TLS/RTP/SAVPF 111\r\na=recvonly\r\n"); + assert!(check_answer(&audio, &allowed(&["52.112.0.0/14"])).is_err()); + let video_first = ANSWER.replace("m=application 3478 UDP/DTLS/SCTP webrtc-datachannel", "m=video 3478 UDP/TLS/RTP/SAVPF 96"); + assert!(check_answer(&video_first, &allowed(&["52.112.0.0/14"])).is_err()); + assert!(check_answer(ANSWER, &allowed(&["40.0.0.0/8"])).is_err()); + assert!(check_answer("v=0\r\n", &allowed(&["52.112.0.0/14"])).is_err()); + } + + #[tokio::test] + async fn a_module_without_media_access_cannot_open() { + let (events, _queue) = mpsc::channel(1); + let result = Connections::default().open(None, "main-channel", &events).await; + assert!(matches!(result, Err(HttpError::Blocked(_))), "{result:?}"); + } + + #[test] + fn pending_messages_keep_to_their_limits() { + let mut pending = Pending::default(); + pending.push(vec![0; limits::MAX_RTC_MESSAGE + 1]); + for _ in 0..limits::MAX_RTC_MESSAGES_PER_SECOND + 5 { + pending.push(vec![1; 10]); + } + let Some(GuestEvent::RtcData { messages, dropped, .. }) = pending.take(7) else { panic!("a batch") }; + assert_eq!(messages.len(), limits::MAX_RTC_MESSAGES_PER_SECOND); + assert_eq!(dropped, 6); + assert!(pending.take(7).is_none()); + } +} diff --git a/crates/open-mess-host/src/runtime.rs b/crates/open-mess-host/src/runtime.rs index 618576e..d9260ff 100644 --- a/crates/open-mess-host/src/runtime.rs +++ b/crates/open-mess-host/src/runtime.rs @@ -14,6 +14,7 @@ use crate::error::HostError; use crate::hosts::HostList; use crate::limits::{self, Limits, ModuleLimiter}; use crate::net::Net; +use crate::rtc::{Allowed, Connections, Peers}; use crate::sign_in::{Caller, CodeShown, SignInPrompts}; use crate::storage::Storage; use crate::timers::Timers; @@ -30,6 +31,14 @@ pub struct Manifest { pub sign_in_hosts: Vec, pub wam: Option, pub extra_tools: Vec, + pub media: Option, +} + +/// Mirrors `media-access` in `wit/module.wit`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MediaAccess { + /// CIDR ranges of the media peers. + pub peers: Vec, } /// Mirrors `wam-identity` in `wit/module.wit`. @@ -161,6 +170,8 @@ pub(crate) struct ModuleEnv { pub(crate) sign_in: SignInPrompts, /// The manifest's Windows account identity, the only one `wam-token` asks for. pub(crate) wam: Option, + /// Who the module's media connections may reach; none without `media` in the manifest. + pub(crate) media: Option, } /// The state a module instance runs with: a WASI context with nothing inherited, plus the host @@ -172,6 +183,7 @@ pub(crate) struct HostState { pub(crate) deadline: Deadline, pub(crate) env: Arc, pub(crate) sockets: Sockets, + pub(crate) rtc: Connections, pub(crate) timers: Timers, pub(crate) storage: Option, /// The largest HTTP body `http.fetch` takes now: `MAX_ATTACHMENT` during `get-attachment`. @@ -203,6 +215,7 @@ impl HostState { deadline, env, sockets: Sockets::default(), + rtc: Connections::default(), timers: Timers::default(), storage: None, max_body: limits::MAX_BODY, @@ -330,6 +343,7 @@ impl Engine { timer_cap: None, sign_in: sign_in.clone(), wam: None, + media: None, }; let (mut actor, state) = Actor::new(self.engine.clone(), pre, self.limits, Arc::new(loading)); let manifest: Manifest = actor @@ -342,6 +356,7 @@ impl Engine { // Not used until the sign-in window (milestone E), but a bad manifest is refused now. HostList::parse(&manifest.sign_in_hosts)?; crate::sign_in::check_scopes(manifest.wam.as_ref())?; + let media = manifest.media.as_ref().map(|media| Peers::parse(&media.peers)).transpose()?.map(|peers| Allowed::new(peers, net.policy())); actor.reset(Arc::new(ModuleEnv { name: manifest.name.clone(), data_dir, @@ -354,6 +369,7 @@ impl Engine { timer_cap: self.timer_cap, sign_in, wam: manifest.wam.clone(), + media, })); let (requests, queue) = mpsc::channel(QUEUE); let crashes_since_load = actor.crashes_since_load.clone(); @@ -447,6 +463,13 @@ impl ModuleHandle { self.request(|reply| Request::SignOut(account.to_string(), reply)).await } + /// Ends now what other people see of the account's sessions (live captions), before the + /// daemon stops, an update pauses the module's events or a scan stops or replaces the module; + /// at most `END_SESSIONS_TIMEOUT` once its turn in the module's queue comes. + pub async fn end_sessions(&self, account: &str) -> Result<(), HostError> { + self.request(|reply| Request::EndSessions(account.to_string(), reply)).await + } + /// Runs a tool: a shared one (spec 8.5) or one of the manifest's extra tools. pub async fn call(&self, account: &str, tool: &str, args_json: &str) -> Result { self.request(|reply| Request::Call { account: account.to_string(), tool: tool.to_string(), args: args_json.to_string(), reply }).await diff --git a/crates/open-mess-host/src/sign_in.rs b/crates/open-mess-host/src/sign_in.rs index aa5b49d..088c547 100644 --- a/crates/open-mess-host/src/sign_in.rs +++ b/crates/open-mess-host/src/sign_in.rs @@ -246,12 +246,16 @@ pub(crate) struct Wam<'a> { impl Wam<'_> { /// A token for `scopes` (and `claims`, a CAE challenge). In a sign-in the user picks the /// account (once; `pick` keeps it for the rest of the call), or the WAM dialog runs when - /// `interactive`; any other call uses the account's stored one, silently. `windows_accounts` - /// are the module accounts with a stored Windows account, which an event uses. + /// `interactive`; any other call uses the account's stored one, silently. `named` is the module + /// account the token is for: a call may name only its own, an event any account of the module + /// (spec 2026-10-29-live-captions, 5.2). `windows_accounts` are the module accounts with a + /// stored Windows account, which an event that names none uses. + #[allow(clippy::too_many_arguments)] async fn token( &self, windows_accounts: &[String], caller: &Caller, + named: Option<&str>, pick: &mut Option, scopes: &[String], interactive: bool, @@ -259,8 +263,12 @@ impl Wam<'_> { ) -> Result { let client_id = allowed(self.identity, self.hosts, scopes)?.client_id.clone(); let asked = TokenRequest { client_id, scopes: scopes.to_vec(), claims: claims.map(str::to_owned) }; - let account = match caller { - Caller::SignIn => match pick { + let account = match (caller, named) { + (Caller::SignIn, Some(_)) => return Err(SignInError::Refused("a sign-in names no account".to_string())), + (Caller::Account(own), Some(account)) if account != own.as_str() => { + return Err(SignInError::Refused("a call may ask for its own account's token only".to_string())); + } + (Caller::SignIn, None) => match pick { Some(account) if !interactive => account.id.clone(), _ => { let (account, token) = self.choose(&asked, interactive).await?; @@ -273,9 +281,10 @@ impl Wam<'_> { } }, _ if interactive => return Err(SignInError::Refused(DIALOG_ONLY_IN_SIGN_IN.to_string())), - Caller::Account(account) => self.stored(account)?, - // An event names no account: the module's only Windows account. - Caller::Event => match windows_accounts { + (Caller::Account(account), _) => self.stored(account)?, + (Caller::Event, Some(account)) => self.stored(account)?, + // An event that names no account: the module's only Windows account. + (Caller::Event, None) => match windows_accounts { [account] => self.stored(account)?, [] => return Err(SignInError::Refused("no account of this module signed in with Windows".to_string())), _ => return Err(SignInError::Refused("several Windows accounts: ask for a token in a call for one of them".to_string())), @@ -441,10 +450,17 @@ impl Host for HostState { /// A token from Windows for the manifest's identity; `interactive` opens the WAM dialog, /// which only a sign-in from the window may do. A silent request that needs the dialog fails /// with `refused("interaction required")`. - async fn wam_token(&mut self, scopes: Vec, interactive: bool, claims: Option) -> Result { + async fn wam_token( + &mut self, + scopes: Vec, + interactive: bool, + claims: Option, + account: Option, + ) -> Result { self.code_done(); - if scopes.iter().map(String::len).sum::() > MAX_PROMPT_TEXT || claims.as_ref().is_some_and(|claims| claims.len() > MAX_PROMPT_TEXT) { - return Err(SignInError::Refused(format!("the scopes or claims are over {MAX_PROMPT_TEXT} bytes"))); + let long = |text: &Option| text.as_ref().is_some_and(|text| text.len() > MAX_PROMPT_TEXT); + if scopes.iter().map(String::len).sum::() > MAX_PROMPT_TEXT || long(&claims) || long(&account) { + return Err(SignInError::Refused(format!("the scopes, claims or account are over {MAX_PROMPT_TEXT} bytes"))); } // Refused before anything touches Windows (or the tests' fake) or the credential store. allowed(self.env.wam.as_ref(), &self.env.hosts, &scopes)?; @@ -454,8 +470,8 @@ impl Host for HostState { } self.prompts()?; let store = self.secret_store().map_err(SignInError::Refused)?; - let windows_accounts = match self.caller { - Caller::Event => { + let windows_accounts = match (&self.caller, &account) { + (Caller::Event, None) => { self.storage().and_then(|storage| storage.secret_accounts(WAM_ACCOUNT)).map_err(|e| SignInError::Refused(format!("{e:?}")))? } _ => Vec::new(), @@ -470,7 +486,7 @@ impl Host for HostState { store: &*store, calls: WINDOWS, }; - let token = wam.token(&windows_accounts, &caller, &mut pick, &scopes, interactive, claims.as_deref()).await; + let token = wam.token(&windows_accounts, &caller, account.as_deref(), &mut pick, &scopes, interactive, claims.as_deref()).await; self.pick = pick; token } @@ -549,8 +565,12 @@ mod tests { } async fn token(&self, caller: &Caller, pick: &mut Option, interactive: bool) -> Result { + self.named(caller, None, pick, interactive).await + } + + async fn named(&self, caller: &Caller, named: Option<&str>, pick: &mut Option, interactive: bool) -> Result { let windows_accounts = self.storage.secret_accounts(WAM_ACCOUNT).unwrap(); - self.wam(FAKE).token(&windows_accounts, caller, pick, &scopes(), interactive, None).await + self.wam(FAKE).token(&windows_accounts, caller, named, pick, &scopes(), interactive, None).await } } @@ -569,13 +589,13 @@ mod tests { // A scope the user did not approve never reaches Windows, nor does a module without a // WAM identity. for scopes in [vec!["https://graph.example.com/.default".to_string()], vec![SCOPE.into(), "openid".into()], Vec::new()] { - assert!(refused(t.wam(no_wam).token(&[], &Caller::SignIn, &mut pick, &scopes, false, None).await).contains("scope")); + assert!(refused(t.wam(no_wam).token(&[], &Caller::SignIn, None, &mut pick, &scopes, false, None).await).contains("scope")); } t.identity.scopes.clear(); - assert!(t.wam(no_wam).token(&[], &Caller::SignIn, &mut pick, &scopes(), false, None).await.is_err()); + assert!(t.wam(no_wam).token(&[], &Caller::SignIn, None, &mut pick, &scopes(), false, None).await.is_err()); let mut wam = t.wam(no_wam); wam.identity = None; - assert!(refused(wam.token(&[], &Caller::SignIn, &mut pick, &scopes(), false, None).await).contains("no Windows account identity")); + assert!(refused(wam.token(&[], &Caller::SignIn, None, &mut pick, &scopes(), false, None).await).contains("no Windows account identity")); // The client id is always the manifest's: the module has no way to name another. let t = setup(); assert_eq!(t.token(&Caller::SignIn, &mut pick, false).await.unwrap(), format!("token {CLIENT} {SCOPE} w1")); @@ -612,9 +632,9 @@ mod tests { t.identity.scopes.push("https://*.sharepoint.com/.default".into()); let site = vec!["https://contoso-my.sharepoint.com/.default".to_string()]; // A pattern's audience must be on the host list too. - assert!(refused(t.wam(FAKE).token(&[], &Caller::SignIn, &mut pick, &site, false, None).await).contains("scope")); + assert!(refused(t.wam(FAKE).token(&[], &Caller::SignIn, None, &mut pick, &site, false, None).await).contains("scope")); t.hosts = HostList::parse(&["*.sharepoint.com".to_string()]).unwrap(); - assert!(t.wam(FAKE).token(&[], &Caller::SignIn, &mut pick, &site, false, None).await.is_ok()); + assert!(t.wam(FAKE).token(&[], &Caller::SignIn, None, &mut pick, &site, false, None).await.is_ok()); } #[tokio::test] @@ -631,6 +651,14 @@ mod tests { // With two Windows accounts, an event can't tell which one. remember(&*t.store, &mut t.storage, "m", "acct2", &account("w1")).unwrap(); assert!(refused(t.token(&Caller::Event, &mut None, false).await).contains("several")); + // Unless it names one: then that account's token (live captions, spec 5.2). + assert_eq!(t.named(&Caller::Event, Some("acct2"), &mut None, false).await.unwrap(), format!("token {CLIENT} {SCOPE} w1")); + assert_eq!(t.named(&Caller::Event, Some("acct"), &mut None, false).await.unwrap(), format!("token {CLIENT} {SCOPE} w2")); + assert!(refused(t.named(&Caller::Event, Some("other"), &mut None, false).await).contains("sign in again")); + // A call names only its own account; a sign-in none. + assert_eq!(t.named(&Caller::Account("acct".into()), Some("acct"), &mut None, false).await.unwrap(), format!("token {CLIENT} {SCOPE} w2")); + assert!(refused(t.named(&Caller::Account("acct".into()), Some("acct2"), &mut None, false).await).contains("own account")); + assert!(refused(t.named(&Caller::SignIn, Some("acct"), &mut None, false).await).contains("names no account")); } #[tokio::test] @@ -672,7 +700,7 @@ mod tests { remember(&*t.store, &mut t.storage, "m", "acct", &account("w2")).unwrap(); // A CAE challenge: the claims the 401 named go with the silent request for the account. let claims = r#"{"access_token":{"nbf":{"essential":true,"value":"1"}}}"#; - let token = t.wam(FAKE).token(&[], &Caller::Account("acct".into()), &mut None, &scopes(), false, Some(claims)).await.unwrap(); + let token = t.wam(FAKE).token(&[], &Caller::Account("acct".into()), None, &mut None, &scopes(), false, Some(claims)).await.unwrap(); assert_eq!(token, format!("token {CLIENT} {SCOPE} w2 claims {claims}")); } @@ -695,7 +723,7 @@ mod tests { ..FAKE }; let mut pick = None; - let result = t.wam(slow).token(&[], &Caller::SignIn, &mut pick, &scopes(), true, None).await; + let result = t.wam(slow).token(&[], &Caller::SignIn, None, &mut pick, &scopes(), true, None).await; assert!(matches!(result, Err(SignInError::TimedOut)), "{result:?}"); assert_eq!(pick, None); } diff --git a/crates/open-mess-host/src/ws.rs b/crates/open-mess-host/src/ws.rs index b08f8b4..4595aad 100644 --- a/crates/open-mess-host/src/ws.rs +++ b/crates/open-mess-host/src/ws.rs @@ -110,6 +110,10 @@ async fn connect(net: &Net, hosts: &HostList, url: &str, headers: &[(String, Str .map_err(|e| network(format!("TLS setup: {e}")))? .with_no_client_auth(); let config = WebSocketConfig::default().max_message_size(Some(limits::MAX_WS_MESSAGE)).max_frame_size(Some(limits::MAX_WS_MESSAGE)); + #[cfg(any(test, feature = "test-support"))] + if let Some(addr) = net.fake_sockets() { + return fake(addr, request, config).await; + } let handshake = async { let stream = tcp(&url, &addrs).await?; let connector = Connector::Rustls(Arc::new(tls)); @@ -119,6 +123,17 @@ async fn connect(net: &Net, hosts: &HostList, url: &str, headers: &[(String, Str tokio::time::timeout(net.limits.connect_timeout, handshake).await.map_err(|_| HttpError::Timeout)? } +/// Test support: the handshake with the loopback server at `addr` over plain ws; the request +/// keeps its `Host` and path (`Net::serving_websockets_from`). +#[cfg(any(test, feature = "test-support"))] +async fn fake(addr: SocketAddr, mut request: tungstenite::handshake::client::Request, config: WebSocketConfig) -> Result { + let path = request.uri().path_and_query().map_or("/", |p| p.as_str()).to_string(); + *request.uri_mut() = format!("ws://{addr}{path}").parse().map_err(network)?; + let stream = TcpStream::connect(addr).await.map_err(network)?; + let (ws, _) = tokio_tungstenite::client_async_with_config(request, MaybeTlsStream::Plain(stream), Some(config)).await.map_err(network)?; + Ok(ws) +} + /// Moves frames between one connection and the module until either side ends it. async fn serve(conn: u32, mut ws: Stream, mut outgoing: mpsc::Receiver, events: mpsc::Sender) { // A received frame waits here for room in the module's queue, without blocking the frames diff --git a/crates/open-mess-host/tests/limits.rs b/crates/open-mess-host/tests/limits.rs index 187d79b..a8c269e 100644 --- a/crates/open-mess-host/tests/limits.rs +++ b/crates/open-mess-host/tests/limits.rs @@ -71,6 +71,23 @@ async fn one_module_crash_does_not_affect_another() { assert_eq!(echo.call("a", "echo", "{}").await.unwrap(), "{}"); } +// Two threads, as in the daemon: the module's busy call holds one, and the caller gives up on the +// other meanwhile. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_call_nobody_waits_for_is_not_run() { + let engine = Engine::with_limits(limits::for_tests()).unwrap(); + // Its own storage, where test-echo counts the calls. + let dir = tempfile::tempdir().unwrap(); + let echo = engine.load(&common::wasm("open_mess_test_echo"), common::context_in(dir.path().to_path_buf())).await.unwrap(); + // The first call keeps the module busy for 1 s; the caller of the second gives up behind it. + let busy = echo.call("a", "peek", r#"{"answer":"slow"}"#); + let given_up = tokio::time::timeout(Duration::from_millis(200), echo.call("a", "peek", "{}")); + let (busy, given_up) = tokio::join!(busy, given_up); + assert!(busy.is_ok() && given_up.is_err(), "{busy:?} {given_up:?}"); + // Only the first ran. + assert_eq!(echo.call("a", "peeks", "{}").await.unwrap(), r#"{"count":1}"#); +} + #[tokio::test] async fn module_errors_are_not_crashes() { let engine = Engine::with_limits(limits::for_tests()).unwrap(); diff --git a/crates/open-mess-host/tests/rtc.rs b/crates/open-mess-host/tests/rtc.rs new file mode 100644 index 0000000..f472766 --- /dev/null +++ b/crates/open-mess-host/tests/rtc.rs @@ -0,0 +1,247 @@ +//! Module media connections (`rtc`) against a second WebRTC host on loopback that answers with an +//! ECDSA P-384 certificate signing with SHA-256, as Teams' media processor does (spec +//! 2026-10-29-live-captions, 5.2). The test-echo module logs what its `events` export receives; the +//! tests read those logs. + +// clippy.toml allows unwrap in #[test] functions only; the peer and log helpers are test code too. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)] + +mod common; + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, Once}; +use std::time::Duration; + +use common::load; +use open_mess_host::{Engine, ModuleHandle, limits}; +use p384::ecdsa::signature::hazmat::PrehashSigner; +use rtc::crypto::{CryptoError, PublicKey, PublicKeyEncoding, SignatureScheme, SigningKey}; +use rtc::peer_connection::certificate::RTCCertificate; +use rustls::pki_types::CertificateDer; +use serde_json::{Value, json}; +use sha2::Digest; +use tokio::sync::mpsc; +use webrtc::data_channel::{DataChannel, DataChannelEvent}; +use webrtc::media_stream::track_remote::TrackRemote; +use webrtc::peer_connection::{ + MediaEngine, PeerConnection, PeerConnectionBuilder, PeerConnectionEventHandler, RTCConfigurationBuilder, RTCIceGatheringState, + RTCSessionDescription, SettingEngineBuilder, +}; + +static LOGS: Mutex> = Mutex::new(Vec::new()); + +struct Capture; + +impl std::io::Write for Capture { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + LOGS.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +fn capture_logs() { + static INIT: Once = Once::new(); + INIT.call_once(|| tracing_subscriber::fmt().with_writer(|| Capture).with_max_level(tracing::Level::DEBUG).init()); +} + +fn logs() -> String { + String::from_utf8_lossy(&LOGS.lock().unwrap()).into_owned() +} + +async fn wait_for_log(needle: &str) -> bool { + for _ in 0..200 { + if logs().contains(needle) { + return true; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + false +} + +async fn echo() -> ModuleHandle { + capture_logs(); + load(&Engine::with_limits(limits::for_tests()).unwrap(), "open_mess_test_echo").await +} + +async fn call(module: &ModuleHandle, tool: &str, args: Value) -> Value { + serde_json::from_str(&module.call("a", tool, &args.to_string()).await.unwrap()).unwrap() +} + +/// The far end's self-signed certificate: ECDSA P-384, signed with SHA-256, made once for these +/// tests (with Python's `cryptography`) together with its private scalar below - a test key, not +/// a secret. +const PEER_CERTIFICATE: &[&str] = &[ + "308201673081eea003020102020101300a06082a8648ce3d040302301e311c301a06035504030c136f70656e5f6d6573", + "73207465737420706565723020170d3236303130313030303030305a180f32313235313230383030303030305a301e31", + "1c301a06035504030c136f70656e5f6d657373207465737420706565723076301006072a8648ce3d020106052b810400", + "2203620004bf9744877b97e40754757db63c2bb6ce1c6e458fc10546f2cf33d434b92d504b984e7f7100b9c827834207", + "90b8351fcd2217a86292c4438edb3ed169e1a374e9c5a0bfc8a94b5c977f3518f0a8b05fb3de41985a04cc29a1f77b16", + "ae5d991d0b300a06082a8648ce3d0403020368003065023100c226a1a0187a2adc497e8f605f5201a24c1336d6f74f76", + "63ca61b18feb21e036ef5f977bb5718d476e2d2bcddc49953e02301bd353c459157c2b68b9c4c4e269739bf541bd2088", + "e87a23ac6a6175dd8743ebe7dc8d8db9f4fdda5bccc2a50f3d34a5", +]; +const PEER_SCALAR: &str = "d6cfb717f0d9c1cf24a52b06dbb84a78d6982e96df40f33b3b8e0046d3c31ae11c627cbcad6fb22954c29f6d73c08f44"; + +fn hex(text: &str) -> Vec { + (0..text.len()).step_by(2).map(|i| u8::from_str_radix(&text[i..i + 2], 16).unwrap()).collect() +} + +/// The far end's key: P-384, signing with SHA-256 and nothing else - the pairing ring cannot sign +/// but must verify. When it signs, the DTLS library names the TLS pair (ecdsa, sha256) +/// `EcdsaP256Sha256` whatever the curve; on the wire it is the pair Teams sends. +struct P384Sha256 { + key: p384::ecdsa::SigningKey, + public: Vec, +} + +impl SigningKey for P384Sha256 { + fn supports(&self, scheme: SignatureScheme) -> bool { + matches!(scheme, SignatureScheme::EcdsaP256Sha256 | SignatureScheme::EcdsaP384Sha256) + } + + fn public_key(&self) -> PublicKey<'_> { + PublicKey { encoding: PublicKeyEncoding::EcUncompressedPoint, bytes: &self.public } + } + + fn sign(&self, _scheme: SignatureScheme, message: &[u8]) -> Result, CryptoError> { + let signature: p384::ecdsa::Signature = self.key.sign_prehash(&sha2::Sha256::digest(message)).expect("a signature"); + Ok(signature.to_der().as_bytes().to_vec()) + } +} + +fn peer_certificate() -> RTCCertificate { + let key = p384::ecdsa::SigningKey::from_slice(&hex(PEER_SCALAR)).unwrap(); + let public = key.verifying_key().to_sec1_point(false).as_bytes().to_vec(); + let der = CertificateDer::from(hex(&PEER_CERTIFICATE.concat())); + RTCCertificate::from_signing_key(vec![der], Arc::new(P384Sha256 { key, public }), std::time::SystemTime::now() + Duration::from_secs(3600)) +} + +/// The far end: what the test peer received and whether it ever got a media track. +struct PeerEvents { + channels: mpsc::Sender>, + gathered: mpsc::Sender<()>, + track: Arc, +} + +#[async_trait::async_trait] +impl PeerConnectionEventHandler for PeerEvents { + async fn on_ice_gathering_state_change(&self, state: RTCIceGatheringState) { + if state == RTCIceGatheringState::Complete { + self.gathered.try_send(()).ok(); + } + } + + async fn on_data_channel(&self, channel: Arc) { + self.channels.try_send(channel).ok(); + } + + async fn on_track(&self, _track: Arc) { + self.track.store(true, Ordering::SeqCst); + } +} + +struct Peer { + pc: Arc, + channels: mpsc::Receiver>, + track: Arc, +} + +/// A WebRTC host on loopback that answers `offer` with a P-384 certificate signing with SHA-256. +async fn answer(offer: &str) -> (Peer, String) { + let certificate = peer_certificate(); + let (channels_tx, channels) = mpsc::channel(4); + let (gathered_tx, mut gathered) = mpsc::channel(1); + let track = Arc::new(AtomicBool::new(false)); + let pc = PeerConnectionBuilder::new() + .with_configuration(RTCConfigurationBuilder::new().with_certificates(vec![certificate]).build()) + .with_media_engine(MediaEngine::default()) + .with_setting_engine(SettingEngineBuilder::new().with_include_loopback_candidate(true).build()) + .with_handler(Arc::new(PeerEvents { channels: channels_tx, gathered: gathered_tx, track: track.clone() })) + .with_udp_addrs(vec!["127.0.0.1:0".to_string()]) + .build() + .await + .unwrap(); + let pc: Arc = Arc::new(pc); + pc.set_remote_description(RTCSessionDescription::offer(offer.to_string()).unwrap()).await.unwrap(); + let answer = pc.create_answer(None).await.unwrap(); + pc.set_local_description(answer).await.unwrap(); + tokio::time::timeout(Duration::from_secs(5), gathered.recv()).await.ok(); + let sdp = pc.local_description().await.unwrap().sdp; + (Peer { pc, channels, track }, sdp) +} + +async fn open(module: &ModuleHandle, label: &str) -> (u64, String) { + let opened = call(module, "rtc-open", json!({"label": label})).await; + (opened["conn"].as_u64().expect("a connection"), opened["offer"].as_str().unwrap().to_string()) +} + +fn sections(sdp: &str) -> Vec<&str> { + sdp.lines().filter(|line| line.starts_with("m=")).collect() +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_offer_has_only_a_data_channel() { + let module = echo().await; + let (conn, offer) = open(&module, "main-channel").await; + let media = sections(&offer); + assert_eq!(media.len(), 1, "{media:?}"); + assert!(media[0].starts_with("m=application ") && media[0].contains("webrtc-datachannel"), "{media:?}"); + assert!(offer.lines().any(|line| line.starts_with("a=candidate:") && line.contains("127.0.0.1"))); + call(&module, "rtc-close", json!({"conn": conn})).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn messages_from_a_p384_peer_reach_the_module_in_batches() { + let module = echo().await; + let (conn, offer) = open(&module, "main-channel").await; + let (mut peer, answer) = answer(&offer).await; + assert_eq!(call(&module, "rtc-accept", json!({"conn": conn, "answer": answer})).await, json!({})); + let channel = tokio::time::timeout(Duration::from_secs(10), peer.channels.recv()).await.unwrap().unwrap(); + assert_eq!(channel.label().await.unwrap(), "main-channel"); + while !matches!(tokio::time::timeout(Duration::from_secs(10), channel.poll()).await.unwrap(), Some(DataChannelEvent::OnOpen)) {} + for n in 0..3 { + channel.send_text(&format!("caption-{conn}-{n}")).await.unwrap(); + } + assert!(wait_for_log(&format!("rtc {conn} state connected")).await, "{}", logs()); + assert!(wait_for_log(&format!("caption-{conn}-2")).await, "{}", logs()); + // Nothing but the data channel: the far end never got a media track. + assert!(!peer.track.load(Ordering::SeqCst)); + // The module's close ends the connection on the far end too. + call(&module, "rtc-close", json!({"conn": conn})).await; + peer.pc.close().await.ok(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_answer_with_audio_or_without_an_allowed_candidate_is_refused() { + let module = echo().await; + let (conn, offer) = open(&module, "main-channel").await; + let (peer, answer) = answer(&offer).await; + // An audio section in which the far end would send sound. + let audio = format!("{answer}m=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=mid:1\r\na=sendrecv\r\na=rtpmap:111 opus/48000/2\r\n"); + let refused = call(&module, "rtc-accept", json!({"conn": conn, "answer": audio})).await; + assert_eq!(refused["error"], "blocked", "{refused}"); + // Every candidate outside the module's ranges (52.112.0.0/14) and not loopback. + let elsewhere = answer.replace("127.0.0.1", "8.8.8.8"); + let refused = call(&module, "rtc-accept", json!({"conn": conn, "answer": elsewhere})).await; + assert_eq!(refused["error"], "blocked", "{refused}"); + assert!(refused["why"].as_str().unwrap().contains("media ranges"), "{refused}"); + peer.pc.close().await.ok(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_fifth_connection_is_refused() { + let module = echo().await; + let mut conns = Vec::new(); + for _ in 0..limits::MAX_RTC { + conns.push(open(&module, "main-channel").await.0); + } + let refused = call(&module, "rtc-open", json!({"label": "main-channel"})).await; + assert_eq!(refused["error"], "blocked", "{refused}"); + // A closed one makes room again. + call(&module, "rtc-close", json!({"conn": conns[0]})).await; + assert!(open(&module, "main-channel").await.0 > 0); +} diff --git a/crates/open-mess-host/tests/teams/fake.rs b/crates/open-mess-host/tests/teams/fake.rs index e0c10d8..36c16e5 100644 --- a/crates/open-mess-host/tests/teams/fake.rs +++ b/crates/open-mess-host/tests/teams/fake.rs @@ -194,6 +194,74 @@ pub fn message(id: &str, conversation: &str, from: &str, html: &str, ms: i64) -> }) } +/// The callback base the fake Trouter gives (`surl`). +pub const SURL: &str = "https://pub-ent-synthetic-t.trouter.teams.microsoft.com/v4/f/c3ludGhldGlj/"; + +/// A fake Trouter (docs 5 and 10.7): takes the module's sockets, answers `user.authenticate` with +/// `trouter.connected`, keeps what the module sends and passes the test's frames on. +#[derive(Clone)] +pub struct Trouter { + pub addr: SocketAddr, + to_module: Arc>>>, + from_module: Arc>>, +} + +impl Trouter { + pub async fn start() -> Trouter { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let trouter = Trouter { addr: listener.local_addr().unwrap(), to_module: Arc::default(), from_module: Arc::default() }; + let server = trouter.clone(); + tokio::spawn(async move { + while let Ok((stream, _)) = listener.accept().await { + tokio::spawn(server.clone().serve(stream)); + } + }); + trouter + } + + async fn serve(self, stream: tokio::net::TcpStream) { + use futures_util::{SinkExt, StreamExt}; + use tokio_tungstenite::tungstenite::Message; + let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else { return }; + let (to_module, mut frames) = tokio::sync::mpsc::unbounded_channel(); + *self.to_module.lock().unwrap() = Some(to_module); + ws.send(Message::text("1::")).await.ok(); + loop { + tokio::select! { + frame = ws.next() => match frame { + Some(Ok(Message::Text(text))) => { + let text = text.to_string(); + if text.contains("\"user.authenticate\"") { + let connected = json!({"name": "trouter.connected", "args": [{"surl": SURL, "connectparams": {"sr": "c3ludGhldGlj"}, "ttl": "604800"}]}); + ws.send(Message::text(format!("5:::{connected}"))).await.ok(); + } + self.from_module.lock().unwrap().push(text); + } + Some(Ok(_)) => {} + _ => break, + }, + frame = frames.recv() => match frame { + Some(frame) => { + ws.send(Message::text(frame)).await.ok(); + } + None => break, + }, + } + } + } + + /// A downstream request to the module, as Trouter delivers a call callback. + pub fn callback(&self, id: u64, url: &str, body: &Value) -> bool { + let frame = format!("3:::{}", json!({"id": id, "method": "POST", "url": url, "headers": {}, "body": body.to_string()})); + self.to_module.lock().unwrap().as_ref().is_some_and(|socket| socket.send(frame).is_ok()) + } + + /// The frames the module sent. + pub fn seen(&self) -> Vec { + self.from_module.lock().unwrap().clone() + } +} + fn default_reply(seen: &Seen) -> Reply { if seen.method == "POST" && seen.path == AUTHZ_PATH { Reply::json(authz()) } else { Reply::status(404) } } diff --git a/crates/open-mess-host/tests/teams/main.rs b/crates/open-mess-host/tests/teams/main.rs index d3478ed..bec4f52 100644 --- a/crates/open-mess-host/tests/teams/main.rs +++ b/crates/open-mess-host/tests/teams/main.rs @@ -9,6 +9,7 @@ mod common; mod fake; +use std::net::SocketAddr; use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -44,7 +45,7 @@ impl Teams { async fn start() -> Teams { let data = tempfile::tempdir().unwrap(); let (fake, wam) = (Fake::start().await, Wam::new()); - let (module, events) = load(&fake, &wam, data.path(), None).await; + let (module, events) = load(&fake, &wam, data.path(), None, None).await; Teams { module, events, fake, wam, _data: data } } @@ -52,7 +53,7 @@ impl Teams { async fn polling() -> Teams { let data = tempfile::tempdir().unwrap(); let (fake, wam) = (Fake::start().await, Wam::new()); - let (module, events) = load(&fake, &wam, data.path(), Some(POLL)).await; + let (module, events) = load(&fake, &wam, data.path(), Some(POLL), None).await; module.sign_in().await.unwrap(); Teams { module, events, fake, wam, _data: data } } @@ -82,12 +83,24 @@ fn conversations_page(items: Vec) -> impl Fn(&fake::Seen) -> Option) -> (ModuleHandle, mpsc::Receiver) { - let mut engine = Engine::with_limits(limits::for_tests()).unwrap().with_fake_wam(wam.hook()); +async fn load( + fake: &Fake, + wam: &Wam, + data_dir: &Path, + timer_cap: Option, + trouter: Option, +) -> (ModuleHandle, mpsc::Receiver) { + // Live captions wait for the call store within the module's budget, as in the daemon: the + // host's own call limit, not the tests' shorter one. + let calls = if trouter.is_some() { limits::Limits { call_timeout: limits::CALL_TIMEOUT, ..limits::for_tests() } } else { limits::for_tests() }; + let mut engine = Engine::with_limits(calls).unwrap().with_fake_wam(wam.hook()); if let Some(cap) = timer_cap { engine = engine.with_timer_cap(cap); } - let net = Net::with_limits(AddressPolicy::AllowLoopbackForTests, limits::for_tests()).serving_hosts_from(fake.addr); + let mut net = Net::with_limits(AddressPolicy::AllowLoopbackForTests, limits::for_tests()).serving_hosts_from(fake.addr); + if let Some(trouter) = trouter { + net = net.serving_websockets_from(trouter); + } let (events, receiver) = mpsc::channel(64); // Signs in through the fake WAM: no prompt reaches a window. let sign_in = SignInPrompts::new(mpsc::channel(1).0); @@ -413,6 +426,21 @@ async fn read_messages_pages_backwards() { assert!(matches!(module_error(unknown), ModuleError::InvalidArgs(_))); } +#[tokio::test] +async fn read_messages_never_returns_a_list_cut_short() { + let teams = Teams::signed_in().await; + // Every page holds only a call event and points at an older one: the walk reaches its page + // limit before any text message, the limit or `since`. + let path = messages_path(MEETING); + let older = format!("{CHAT}/v1/users/ME/conversations/{MEETING}/messages?view=msnp24Equivalent&pageSize=50&startTime=1&syncState=older"); + let mut event = numbered(101, MEETING, OTHER, ""); + event["messagetype"] = "Event/Call".into(); + teams.fake.on(move |s| (s.path == path).then(|| Reply::json(page("messages", vec![event.clone()], Some(older.as_str()))))); + let cut = teams.call("read_messages", json!({"conversation": MEETING, "since": "2027-01-15T07:00:00Z"})).await; + assert!(matches!(module_error(cut), ModuleError::Platform(why) if why.contains("that far back"))); + assert_eq!(teams.fake.requests(&messages_path(MEETING)).len(), 20); +} + #[tokio::test] async fn foreign_backward_link_is_refused() { let teams = Teams::signed_in().await; @@ -1189,7 +1217,7 @@ async fn restart_does_not_replay_old_messages() { let list = listed.clone(); fake.on(move |s| (s.path == CHAT_PATH).then(|| Reply::json(page("conversations", list.lock().unwrap().clone(), None)))); { - let (module, mut events) = load(&fake, &wam, data.path(), Some(POLL)).await; + let (module, mut events) = load(&fake, &wam, data.path(), Some(POLL), None).await; module.sign_in().await.unwrap(); // Sent after the sign-in, which starts the count, however long loading the module took. let old = conversation(GROUP, None, Some(message("1900000000005", GROUP, OTHER, "

old

", now_ms() + 1000))); @@ -1197,7 +1225,7 @@ async fn restart_does_not_replay_old_messages() { assert_eq!(next_message(&mut events, &fake).await.map(|m| m.message).as_deref(), Some("1900000000005")); } // A new instance, as after a crash or a daemon restart: the same storage, nothing in memory. - let (module, mut events) = load(&fake, &wam, data.path(), Some(POLL)).await; + let (module, mut events) = load(&fake, &wam, data.path(), Some(POLL), None).await; module.restore(ME_OID).await.unwrap(); assert_eq!(next_message(&mut events, &fake).await, None); let new = conversation(GROUP, None, Some(message("1900000000006", GROUP, OTHER, "

new

", now_ms() + 61_000))); @@ -1535,3 +1563,297 @@ async fn sharepoint_refusals_are_answers_not_a_lost_sign_in() { teams.fake.on(transcript_server(unchanged, same_list)); teams.call("get_transcript", json!({"conversation": MEETING})).await.unwrap(); } + +// Live captions (spec 2026-10-29-live-captions, 4): the Teams side of a call against the fake +// server, a fake Trouter and a WebRTC peer on loopback standing in for Teams' media processor. + +const CONTROLLER: &str = "https://api.flightproxy.teams.microsoft.com/api/v2/ep/conv.synthetic/conv/c1"; +const CONTROLLER_PATH: &str = "/api/v2/ep/conv.synthetic/conv/c1"; +const CALL_STORE_PATH: &str = "/api/v2/ep/api.userstore.skype.com/api/v1/user/8:orgid:00000000-0000-0000-0000-000000000001/conversations"; +const REGISTRAR_PATH: &str = "/registrar/prod/V2/registrations"; + +impl Teams { + /// Signed in, its WebSockets served by a fake Trouter. + async fn live() -> (Teams, fake::Trouter) { + let data = tempfile::tempdir().unwrap(); + let (fake, wam, trouter) = (Fake::start().await, Wam::new(), fake::Trouter::start().await); + let (module, events) = load(&fake, &wam, data.path(), None, Some(trouter.addr)).await; + module.sign_in().await.unwrap(); + (Teams { module, events, fake, wam, _data: data }, trouter) + } +} + +/// The conversation as the service answers the join and reads of it: the leave link, and a roster +/// with the user's endpoints and the captions bot. +fn live_conversation(user_endpoints: &Value) -> Value { + json!({"links": {"leave": format!("{CONTROLLER}/leave")}, "roster": {"participants": { + ME: {"state": "active", "endpoints": user_endpoints}, + "28:00000000-0000-0000-0000-0000000000bb": {"endpoints": {"bot": {"endpointMetadata": {"processingModes": {"closedCaptions": {"state": "Active"}}}}}}, + }}}) +} + +/// A meeting with captions on, the user in it on the endpoints `user_endpoints` (open_mess's own +/// is never listed, so any is another device), and the call store's item for it. +fn meeting_server(user_endpoints: Value, in_call_store: bool) -> impl Fn(&fake::Seen) -> Option { + move |s| { + let path = s.path.as_str(); + match s.method.as_str() { + "GET" if path == CALL_STORE_PATH => { + let item = json!({"conversationController": CONTROLLER, "groupContext": {"threadId": MEETING, "messageId": "0"}, "meetingInfo": {"organizerId": ME_OID}}); + Some(Reply::json(if in_call_store { json!([item]) } else { json!([]) })) + } + "POST" if path == REGISTRAR_PATH => Some(Reply::status(202)), + "DELETE" if path.starts_with(REGISTRAR_PATH) => Some(Reply::status(202)), + "GET" | "POST" if path == CONTROLLER_PATH => Some(Reply::json(live_conversation(&user_endpoints))), + "POST" if path == format!("{CONTROLLER_PATH}/leave") => Some(Reply::status(204)), + "POST" if path == format!("{CONTROLLER_PATH}/ack") => Some(Reply::status(202)), + _ => None, + } + } +} + +async fn wait_for(mut found: impl FnMut() -> Option) -> T { + for _ in 0..100 { + if let Some(found) = found() { + return found; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + panic!("not within 10 s"); +} + +fn requests(fake: &Fake, method: &str, path: &str) -> Vec { + fake.seen().into_iter().filter(|s| s.method == method && s.path == path).collect() +} + +/// The module's MS-SDP offer as plain SDP for the test's peer, and the peer's answer as Teams gives +/// it (docs "Live results" 16 and 18). +fn plain_offer(ms: &str) -> String { + let lines = ms.lines().filter_map(|l| match l.strip_prefix("m=x-data ") { + Some(rest) => Some(format!("m=application {} UDP/DTLS/SCTP webrtc-datachannel", rest.split(' ').next().unwrap())), + None => (!["a=rtpmap", "a=fmtp", "a=x-", "a=label", "b="].iter().any(|p| l.starts_with(p))).then(|| l.to_string()), + }); + lines.collect::>().join("\r\n") + "\r\n" +} + +fn ms_answer(plain: &str) -> String { + let lines = plain.lines().filter_map(|l| match l.strip_prefix("m=application ") { + Some(rest) => Some(format!("m=x-data {} RTP/SAVP 127 126\r\na=x-data-protocol:sctp\r\na=label:data", rest.split(' ').next().unwrap())), + None => (!["a=extmap-allow-mixed", "a=msid-semantic", "a=ice-options"].iter().any(|p| l.starts_with(p))).then(|| l.to_string()), + }); + lines.collect::>().join("\r\n") + "\r\n" +} + +struct MediaPeer { + pc: Arc, + channels: mpsc::Receiver>, +} + +struct MediaEvents { + channels: mpsc::Sender>, + gathered: mpsc::Sender<()>, +} + +#[async_trait::async_trait] +impl webrtc::peer_connection::PeerConnectionEventHandler for MediaEvents { + async fn on_ice_gathering_state_change(&self, state: webrtc::peer_connection::RTCIceGatheringState) { + if state == webrtc::peer_connection::RTCIceGatheringState::Complete { + self.gathered.try_send(()).ok(); + } + } + + async fn on_data_channel(&self, channel: Arc) { + self.channels.try_send(channel).ok(); + } +} + +/// A WebRTC peer on loopback that answers `offer`, as Teams' media processor would. +async fn media_peer(offer: &str) -> (MediaPeer, String) { + use webrtc::peer_connection::{ + MediaEngine, PeerConnection, PeerConnectionBuilder, RTCConfigurationBuilder, RTCSessionDescription, SettingEngineBuilder, + }; + let (channels_tx, channels) = mpsc::channel(4); + let (gathered_tx, mut gathered) = mpsc::channel(1); + let pc = PeerConnectionBuilder::new() + .with_configuration(RTCConfigurationBuilder::new().build()) + .with_media_engine(MediaEngine::default()) + .with_setting_engine(SettingEngineBuilder::new().with_include_loopback_candidate(true).build()) + .with_handler(Arc::new(MediaEvents { channels: channels_tx, gathered: gathered_tx })) + .with_udp_addrs(vec!["127.0.0.1:0".to_string()]) + .build() + .await + .unwrap(); + let pc: Arc = Arc::new(pc); + pc.set_remote_description(RTCSessionDescription::offer(offer.to_string()).unwrap()).await.unwrap(); + let answer = pc.create_answer(None).await.unwrap(); + pc.set_local_description(answer).await.unwrap(); + tokio::time::timeout(Duration::from_secs(5), gathered.recv()).await.ok(); + let sdp = pc.local_description().await.unwrap().sdp; + (MediaPeer { pc, channels }, sdp) +} + +/// A caption message on the data channel (docs 10.7): the 11-byte header, data id 3, one final +/// result. +fn caption_message(text: &str) -> Vec { + let result = json!({"isFinal": true, "text": text, "displayName": "Synthetic Person Two", "spokenLanguage": "en-us", "userId": OTHER}); + let mut message = vec![0x10, 11, 0x80 | 3, 0, 1, 0, 0, 0, 0, 7, 0]; + message.extend(json!({"recognitionResults": [result], "events": [], "version": 1}).to_string().as_bytes()); + message +} + +#[tokio::test(flavor = "multi_thread")] +async fn live_captions_are_joined_read_and_left() { + let (teams, trouter) = Teams::live().await; + teams.fake.on(meeting_server(json!({"official-client": {}}), true)); + assert_eq!(teams.call("join_captions", json!({"conversation": MEETING})).await.unwrap(), json!({"state": "joining"})); + // Trouter, the registration, then the join with the skype token and no bearer. + let join = wait_for(|| requests(&teams.fake, "POST", CONTROLLER_PATH).pop()).await; + assert!(trouter.seen().iter().any(|f| f.contains("\"user.authenticate\""))); + assert_eq!(requests(&teams.fake, "POST", REGISTRAR_PATH).len(), 1); + assert_eq!((join.header("x-skypetoken"), join.header("authorization")), (Some("synthetic-skype-token"), None)); + let body = join.json(); + assert_eq!((body["endpointState"]["state"]["isMuted"].as_bool(), &body["callInvitation"]["callModalities"]), (Some(true), &json!([]))); + assert_eq!(body["groupChat"]["threadId"], MEETING); + let offer = body["callInvitation"]["mediaContent"]["blob"].as_str().unwrap(); + assert!(offer.contains("m=x-data ") && offer.matches("m=").count() == 1, "{offer}"); + let acceptance = body["callInvitation"]["links"]["acceptance"].as_str().unwrap(); + assert!(acceptance.starts_with(&format!("{}callAgent/", fake::SURL)) && acceptance.ends_with("/call/acceptance/")); + // Teams' media processor answers on Trouter; the data channel opens and a caption comes. + let (mut peer, answer) = media_peer(&plain_offer(offer)).await; + let accepted = + json!({"callAcceptance": {"mediaContent": {"blob": ms_answer(&answer)}, "links": {"acknowledgement": format!("{CONTROLLER}/ack")}}}); + assert!(trouter.callback(1, acceptance, &accepted)); + let channel = tokio::time::timeout(Duration::from_secs(10), peer.channels.recv()).await.unwrap().unwrap(); + while !matches!( + tokio::time::timeout(Duration::from_secs(10), channel.poll()).await.unwrap(), + Some(webrtc::data_channel::DataChannelEvent::OnOpen) + ) {} + channel.send(caption_message("Hello from the meeting.").as_slice().into()).await.unwrap(); + let mut page = Value::Null; + for _ in 0..100 { + page = teams.call("read_captions", json!({"conversation": MEETING})).await.unwrap(); + if page["untrusted"]["cues"].as_array().is_some_and(|cues| !cues.is_empty()) { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert_eq!((page["state"].as_str(), page["captions_on"].as_bool(), page["next_cursor"].as_u64()), (Some("live"), Some(true), Some(1)), "{page}"); + assert_eq!(page["untrusted"]["cues"][0]["speaker"], "Synthetic Person Two"); + assert_eq!(page["untrusted"]["cues"][0]["text"], "Hello from the meeting."); + assert_eq!(page["language"], "en-us"); + assert_eq!(requests(&teams.fake, "POST", &format!("{CONTROLLER_PATH}/ack")).len(), 1); + assert!(trouter.seen().iter().any(|f| f.starts_with("3:::") && f.contains("\"status\":200"))); + // Another meeting's read shows nothing of this one. + let other = teams.call("read_captions", json!({"conversation": GROUP})).await.unwrap(); + assert_eq!((other["state"].as_str(), &other["untrusted"]["cues"]), (Some("not_joined"), &json!([]))); + // Leaving: `leave` as open_mess's endpoint and the registration's removal. + assert_eq!(teams.call("leave_captions", json!({"conversation": MEETING})).await.unwrap(), json!({"state": "ended"})); + let leave = requests(&teams.fake, "POST", &format!("{CONTROLLER_PATH}/leave")); + assert_eq!(leave.len(), 1); + assert_eq!(leave[0].json()["participants"]["from"]["endpointId"], body["participants"]["from"]["endpointId"]); + assert_eq!(teams.fake.seen().iter().filter(|s| s.method == "DELETE" && s.path.starts_with(REGISTRAR_PATH)).count(), 1); + let session = page["session"].as_str().unwrap(); + let after = teams.call("read_captions", json!({"conversation": MEETING, "session": session, "cursor": 0})).await.unwrap(); + assert_eq!((after["state"].as_str(), after["reason"].as_str(), after["next_cursor"].as_u64()), (Some("ended"), Some("left"), Some(1))); + let changed = teams.call("read_captions", json!({"conversation": MEETING, "session": "b3RoZXI", "cursor": 0})).await; + assert!(matches!(module_error(changed), ModuleError::InvalidArgs(why) if why.contains("session changed"))); + peer.pc.close().await.ok(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn live_captions_end_at_once_when_the_user_is_not_in_the_meeting() { + let (teams, _trouter) = Teams::live().await; + // The call store lists the meeting, but the roster has no other endpoint of the user. + teams.fake.on(meeting_server(json!({}), true)); + teams.call("join_captions", json!({"conversation": MEETING})).await.unwrap(); + let leave = wait_for(|| requests(&teams.fake, "POST", &format!("{CONTROLLER_PATH}/leave")).pop()).await; + assert_eq!(leave.json()["callTransactionEnd"]["phrase"], "LOCAL_USER_INITIATED"); + let page = teams.call("read_captions", json!({"conversation": MEETING})).await.unwrap(); + assert_eq!((page["state"].as_str(), page["reason"].as_str()), (Some("failed"), Some("user_not_in_meeting"))); + // Another session of the account can start again. + teams.call("join_captions", json!({"conversation": MEETING})).await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_end_of_the_leg_is_a_refusal_and_the_end_of_the_conversation_the_meeting_end() { + let (teams, trouter) = Teams::live().await; + teams.fake.on(meeting_server(json!({"official-client": {}}), true)); + // The session's state and reason once it is no longer joining. + async fn settled(teams: &Teams) -> (String, String) { + for _ in 0..100 { + let page = teams.call("read_captions", json!({"conversation": MEETING})).await.unwrap(); + if page["state"] != "joining" { + return (page["state"].as_str().unwrap_or("").into(), page["reason"].as_str().unwrap_or("").into()); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + panic!("not within 10 s"); + } + let joins = |teams: &Teams| requests(&teams.fake, "POST", CONTROLLER_PATH); + // `/call/end/` ends open_mess's leg (430/10067 came live when Teams refused it, "Live results" 19). + teams.call("join_captions", json!({"conversation": MEETING})).await.unwrap(); + let join = wait_for(|| joins(&teams).pop()).await.json(); + let end = join["callInvitation"]["links"]["end"].as_str().unwrap().to_string(); + assert!(trouter.callback(1, &end, &json!({"callEnd": {"code": 430, "subCode": 10067}}))); + assert_eq!(settled(&teams).await, ("failed".into(), "refused".into())); + // `/conversation/conversationEnd/` is the meeting's end. + teams.call("join_captions", json!({"conversation": MEETING})).await.unwrap(); + let join = wait_for(|| joins(&teams).get(1).map(|join| join.json())).await; + let end = join["conversationRequest"]["links"]["conversationEnd"].as_str().unwrap().to_string(); + assert!(trouter.callback(2, &end, &json!({"conversationEnd": {"code": 0}}))); + assert_eq!(settled(&teams).await, ("ended".into(), "meeting_ended".into())); +} + +#[tokio::test(flavor = "multi_thread")] +async fn joining_needs_the_meeting_in_the_call_store_and_one_session_an_account() { + let (teams, trouter) = Teams::live().await; + teams.fake.on(meeting_server(json!({"official-client": {}}), false)); + let refused = module_error(teams.call("join_captions", json!({"conversation": MEETING})).await); + assert!(matches!(&refused, ModuleError::Platform(why) if why.contains("does not list you in this meeting")), "{refused:?}"); + // Three reads of the call store, and nothing reached the meeting or Trouter. + assert_eq!(requests(&teams.fake, "GET", CALL_STORE_PATH).len(), 3); + assert!(requests(&teams.fake, "POST", CONTROLLER_PATH).is_empty() && trouter.seen().is_empty()); + teams.fake.on(meeting_server(json!({"official-client": {}}), true)); + teams.call("join_captions", json!({"conversation": MEETING})).await.unwrap(); + let again = module_error(teams.call("join_captions", json!({"conversation": GROUP})).await); + assert!(matches!(&again, ModuleError::Platform(why) if why.contains("leave it first")), "{again:?}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn end_sessions_leaves_the_meeting() { + let (teams, _trouter) = Teams::live().await; + teams.fake.on(meeting_server(json!({"official-client": {}}), true)); + teams.call("join_captions", json!({"conversation": MEETING})).await.unwrap(); + wait_for(|| requests(&teams.fake, "POST", CONTROLLER_PATH).pop()).await; + teams.module.end_sessions(ME_OID).await.unwrap(); + assert_eq!(requests(&teams.fake, "POST", &format!("{CONTROLLER_PATH}/leave")).len(), 1); + let page = teams.call("read_captions", json!({"conversation": MEETING})).await.unwrap(); + assert_eq!((page["state"].as_str(), page["reason"].as_str()), (Some("ended"), Some("update"))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_failed_leave_is_sent_again_from_the_session_record() { + let (teams, _trouter) = Teams::live().await; + // `leave` fails until the test lets it through; the rest is the usual meeting. + let status = Arc::new(AtomicUsize::new(503)); + let (meeting, answer) = (meeting_server(json!({"official-client": {}}), true), status.clone()); + let leave_path = format!("{CONTROLLER_PATH}/leave"); + teams.fake.on(move |s| if s.path == leave_path { Some(Reply::status(answer.load(Ordering::SeqCst) as u16)) } else { meeting(s) }); + let leaves = |teams: &Teams| requests(&teams.fake, "POST", &format!("{CONTROLLER_PATH}/leave")).len(); + teams.call("join_captions", json!({"conversation": MEETING})).await.unwrap(); + wait_for(|| requests(&teams.fake, "POST", CONTROLLER_PATH).pop()).await; + assert_eq!(teams.call("leave_captions", json!({"conversation": MEETING})).await.unwrap(), json!({"state": "ended"})); + assert_eq!(leaves(&teams), 1); + // A new instance (after a crash, or the daemon's start) leaves from the record: the + // conversation is read for its `leave` link, and the record stays while it fails. + let reads = requests(&teams.fake, "GET", CONTROLLER_PATH).len(); + teams.module.restore(ME_OID).await.unwrap(); + assert_eq!((leaves(&teams), requests(&teams.fake, "GET", CONTROLLER_PATH).len()), (2, reads + 1)); + // Signing out tries once more, with no session holding the leg; then the record is gone. + status.store(204, Ordering::SeqCst); + teams.module.sign_out(ME_OID).await.unwrap(); + assert_eq!(leaves(&teams), 3); + teams.module.sign_in().await.unwrap(); + teams.module.restore(ME_OID).await.unwrap(); + assert_eq!(leaves(&teams), 3); +} diff --git a/crates/open-mess/Cargo.toml b/crates/open-mess/Cargo.toml index fdab581..dbbbd54 100644 --- a/crates/open-mess/Cargo.toml +++ b/crates/open-mess/Cargo.toml @@ -64,6 +64,9 @@ thiserror = "2.0.21" tokio = { version = "1.54.1", features = ["rt-multi-thread", "macros", "io-std", "io-util", "net", "process", "signal", "sync", "time"] } # Sign-in pages' URLs (ui/sign_in_page.rs); the version the module host builds. url = "2.5.8" +# OPEN_MESS_LOG: the daemon's and modules' logs on stderr, for diagnostics (daemon.rs); test-echo +# logs the timer events tests read (tests/update_quiesce.rs). +tracing-subscriber = { version = "0.3.23", default-features = false, features = ["fmt", "std"] } [target.'cfg(windows)'.dependencies] # The `Show all` dialog of an approval. @@ -95,8 +98,6 @@ minisign = "0.10.0" open-mess-host = { path = "../open-mess-host", features = ["test-support"] } tempfile = "3.27.0" tokio = { version = "1.54.1", features = ["sync", "test-util"] } -# The module logs a test reads: test-echo logs the timer events it gets (tests/update_quiesce.rs). -tracing-subscriber = { version = "0.3.23", default-features = false, features = ["fmt", "std"] } [lints] workspace = true diff --git a/crates/open-mess/src/accounts.rs b/crates/open-mess/src/accounts.rs index 16b31fe..8025f5b 100644 --- a/crates/open-mess/src/accounts.rs +++ b/crates/open-mess/src/accounts.rs @@ -6,9 +6,10 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use keyring_core::api::CredentialStore; -use open_mess_host::{AccountInfo, Attachment, HostError, ModuleError, SessionState}; +use open_mess_host::{AccountInfo, Attachment, HostError, ModuleError, ModuleHandle, SessionState}; use rusqlite::OptionalExtension; use serde_json::Value; +use tokio::sync::watch; use tokio::task::JoinSet; use crate::Error; @@ -69,11 +70,95 @@ pub struct Accounts { /// Held while an attachment is saved and while a removed account's attachments are deleted, so /// a download that ends during a removal leaves no file behind. attachments: Mutex<()>, + module_writes: ModuleWrites, + /// Held for a whole scan of the modules folder: scans run one after the other. + scans: tokio::sync::Mutex<()>, +} + +/// The block on the modules' write tools (spec 2026-10-29-live-captions, 4.3). While an update's +/// phase A or a scan of the modules folder holds one, no module write tool makes an action and no +/// approved one calls its module; the holder waits for the module calls already running before it +/// ends the modules' sessions, so no join gets in after them. +pub struct ModuleWrites(watch::Sender); + +#[derive(Clone, Copy, Default)] +struct WriteCounts { + /// The blocks held: the tools work again only when every holder lifted its own. + blocks: usize, + /// Module calls of approved actions running. + running: usize, +} + +impl Default for ModuleWrites { + fn default() -> ModuleWrites { + ModuleWrites(watch::Sender::new(WriteCounts::default())) + } +} + +impl ModuleWrites { + pub fn blocked(&self) -> bool { + self.0.borrow().blocks > 0 + } + + /// Blocks the module write tools until the guard is dropped. + pub fn block(&self) -> WriteBlock<'_> { + self.0.send_modify(|counts| counts.blocks += 1); + WriteBlock(self) + } + + /// Returns once no module call of an approved action runs. + pub async fn finished(&self) { + let _ = self.0.subscribe().wait_for(|counts| counts.running == 0).await; + } + + /// A module call of an approved action, counted while the guard lives; None while blocked. + pub(crate) fn start(&self) -> Option> { + let mut started = false; + self.0.send_if_modified(|counts| { + started = counts.blocks == 0; + if started { + counts.running += 1; + } + started + }); + started.then_some(RunningWrite(self)) + } +} + +/// A block of the module write tools; lifted when dropped. +pub struct WriteBlock<'a>(&'a ModuleWrites); + +impl Drop for WriteBlock<'_> { + fn drop(&mut self) { + self.0.0.send_modify(|counts| counts.blocks = counts.blocks.saturating_sub(1)); + } +} + +/// A module call of an approved action that [`ModuleWrites::start`] let in. +pub(crate) struct RunningWrite<'a>(&'a ModuleWrites); + +impl Drop for RunningWrite<'_> { + fn drop(&mut self) { + self.0.0.send_modify(|counts| counts.running = counts.running.saturating_sub(1)); + } } impl Accounts { pub fn new(store: Store, modules: Modules, data_dir: PathBuf, credentials: Arc) -> Accounts { - Accounts { store: Mutex::new(store), modules, data_dir, credentials, attachments: Mutex::new(()) } + Accounts { + store: Mutex::new(store), + modules, + data_dir, + credentials, + attachments: Mutex::new(()), + module_writes: ModuleWrites::default(), + scans: tokio::sync::Mutex::new(()), + } + } + + /// The block on the modules' write tools. + pub fn module_writes(&self) -> &ModuleWrites { + &self.module_writes } /// Never held across an await. @@ -256,12 +341,18 @@ impl Accounts { /// restarts the module `restart` if it was stopped after repeated crashes. Returns the modules /// that stopped, started or were replaced. Their accounts' waiting actions expire in the same /// step, before any of them can be approved and run on another module than the one that - /// described them; their sessions are then restored, or marked offline. + /// described them; their sessions are then restored, or marked offline. From before the swap + /// until the versions that stopped ended their sessions, the module write tools are blocked, so + /// a new version takes no join before the old one has left (spec 2026-10-29-live-captions, 4.3). pub async fn rescan_modules(&self, restart: Option<&str>) -> Result, Error> { + // A second scan waits for this one's end, and so never lifts its block. + let _scan = self.scans.lock().await; let found = self.modules.load().await?; - let changed = { + let block = self.module_writes.block(); + self.module_writes.finished().await; + let (changed, stopped) = { let store = self.store(); - let changed = self.modules.adopt(&store, found, restart)?; + let (changed, stopped) = self.modules.adopt(&store, found, restart)?; for module in &changed { store.conn().execute( "UPDATE pending SET state = 'expired', args = '', detail = 'its module stopped or was replaced before it was approved' @@ -269,12 +360,61 @@ impl Accounts { [module], )?; } - changed + (changed, stopped) }; + // The versions that stopped leave what others see before they go (spec + // 2026-10-29-live-captions, 4.3). + self.end_sessions(&stopped).await; + drop(stopped); + drop(block); self.restore_where(|module| changed.iter().any(|name| name == module)).await?; Ok(changed) } + /// Asks each module to end now what other people see of its accounts' sessions (live + /// captions): `end-sessions` for every account of the module, one at a time, each limited by + /// the host (spec 2026-10-29-live-captions, 4.3). Before the daemon stops, an update pauses the + /// modules' events, or a scan drops a module. A module that fails is passed over. + pub async fn end_sessions(&self, handles: &[ModuleHandle]) { + let Ok(accounts) = self.list() else { return }; + for handle in handles { + let Ok(manifest) = handle.manifest() else { continue }; + for account in accounts.iter().filter(|account| account.module == manifest.name) { + let _ = handle.end_sessions(&account.module_account).await; + } + } + } + + /// Restores the accounts of a module the moment it crashes, so that a new instance starts at + /// once and ends the sessions the crashed one left (spec 2026-10-29-live-captions, 4.3, R3); + /// the host otherwise starts one only at the module's next call. Runs while the modules do. + pub async fn restore_after_crashes(self: Arc) { + let mut changes = self.modules.changes(); + // Each loaded module and the crashes seen of it. + let mut seen: Vec<(ModuleHandle, u32)> = Vec::new(); + while changes.changed().await.is_ok() { + let handles = self.modules.handles(); + seen.retain(|(old, _)| handles.iter().any(|handle| handle.same(old))); + for handle in handles { + let crashes = handle.crashes_since_load(); + let before = match seen.iter_mut().find(|(old, _)| old.same(&handle)) { + Some((_, count)) => std::mem::replace(count, crashes), + None => { + seen.push((handle.clone(), crashes)); + 0 + } + }; + if crashes > before + && handle.state() != open_mess_host::ModuleState::Disabled + && let Ok(manifest) = handle.manifest() + { + // ponytail: a failed restore leaves the stored states, as at the daemon's start. + let _ = self.restore_where(|module| module == manifest.name).await; + } + } + } + } + /// Restores the sessions of the accounts of the modules `module` picks. async fn restore_where(&self, module: impl Fn(&str) -> bool) -> Result<(), Error> { let mut restores = JoinSet::new(); diff --git a/crates/open-mess/src/approval.rs b/crates/open-mess/src/approval.rs index e443da0..11f342f 100644 --- a/crates/open-mess/src/approval.rs +++ b/crates/open-mess/src/approval.rs @@ -26,6 +26,8 @@ pub const APP_ID: &str = "open_mess.daemon"; pub const UPDATE_APP_ID: &str = "open_mess.update"; /// Participants a toast names; the others are counted. const NAMED_PARTICIPANTS: usize = 5; +/// The most of a module tool's description a title shows: its first sentence. +const TITLE_SENTENCE: usize = 100; /// Windows shows at most four lines of a toast's two text elements (the title has its own two). /// Counted at 24 narrow characters a line (see [`lines`]), which holds even for the widest letters /// at the default text size, so what a toast can approve is never cut off. @@ -61,10 +63,13 @@ pub struct Prompt { pub conversation: String, /// Every participant's name; none for a channel. pub participants: Vec, - /// The text; an edit's text before and after; a reaction and the message it is under. + /// The text; an edit's text before and after; a reaction and the message it is under; a module + /// tool's description and arguments. pub content: String, /// The language of the toast and the dialog. pub lang: Lang, + /// What comes before the participants. + with: &'static str, } /// What a toast shows. @@ -80,19 +85,22 @@ pub struct ToastText { impl Prompt { pub fn new(preview: &Preview, lang: Lang) -> Prompt { - let title = match (preview.kind, lang) { - (Kind::Send, Lang::Pl) => "open_mess: zatwierdź wysłanie", - (Kind::Edit, Lang::Pl) => "open_mess: zatwierdź edycję", - (Kind::React, Lang::Pl) => "open_mess: zatwierdź reakcję", - (Kind::Send, Lang::En) => "open_mess: approve send", - (Kind::Edit, Lang::En) => "open_mess: approve edit", - (Kind::React, Lang::En) => "open_mess: approve reaction", + let field = |text: &Option| clean(text.as_deref().unwrap_or_default()); + let title = match (&preview.kind, lang) { + (Kind::Send, Lang::Pl) => "open_mess: zatwierdź wysłanie".into(), + (Kind::Edit, Lang::Pl) => "open_mess: zatwierdź edycję".into(), + (Kind::React, Lang::Pl) => "open_mess: zatwierdź reakcję".into(), + (Kind::Send, Lang::En) => "open_mess: approve send".into(), + (Kind::Edit, Lang::En) => "open_mess: approve edit".into(), + (Kind::React, Lang::En) => "open_mess: approve reaction".into(), + (Kind::ModuleTool(_), _) => { + format!("open_mess: {} - {}", lang.pick("zatwierdź", "approve"), first_sentence(&field(&preview.description))) + } }; let conversation = format!("{} - {}", clean(&preview.account), clean(&preview.conversation_title)); let participants = preview.participants.iter().map(|name| clean(name)).collect(); - let field = |text: &Option| clean(text.as_deref().unwrap_or_default()); let (text, target, reaction) = (field(&preview.text), field(&preview.target_text), field(&preview.reaction)); - let content = match (preview.kind, &preview.target_text, lang) { + let content = match (&preview.kind, &preview.target_text, lang) { (Kind::Send, None, _) => text, (Kind::Send, Some(_), Lang::Pl) => format!("{text}\n\nW odpowiedzi na: {target}"), (Kind::Send, Some(_), Lang::En) => format!("{text}\n\nIn reply to: {target}"), @@ -100,15 +108,36 @@ impl Prompt { (Kind::Edit, _, Lang::En) => format!("Before: {target}\nAfter: {text}"), (Kind::React, _, Lang::Pl) => format!("{reaction} pod: {target}"), (Kind::React, _, Lang::En) => format!("{reaction} on: {target}"), + (Kind::ModuleTool(_), _, _) => { + let mut content = field(&preview.description); + if !preview.arguments.is_empty() { + content.push('\n'); + } + for argument in &preview.arguments { + content.push_str("\n- "); + content.push_str(&clean(argument)); + } + // The participants are only those in the conversation now (spec + // 2026-10-29-live-captions, 5.1, R6). + content.push_str(lang.pick( + "\n\nZgoda dotyczy rozmowy, nie tylko osób, które są w niej teraz.", + "\n\nThe approval is for the conversation, not only for the people in it now.", + )); + content + } + }; + let with = match preview.kind { + Kind::ModuleTool(_) => lang.pick("\nTeraz w rozmowie: ", "\nIn it now: "), + _ => lang.pick("\nZ: ", "\nWith "), }; - Prompt { title: title.into(), conversation, participants, content, lang } + Prompt { title, conversation, participants, content, lang, with } } /// The conversation and the first `named` participants. fn context(&self, named: usize) -> String { let mut context = self.conversation.clone(); if !self.participants.is_empty() { - context.push_str(self.lang.pick("\nZ: ", "\nWith ")); + context.push_str(self.with); context.push_str(&participants(&self.participants, named)); } context @@ -167,6 +196,14 @@ pub fn participants(names: &[String], named: usize) -> String { shown.join(", ") } +/// A module tool's description up to its first full stop, at most [`TITLE_SENTENCE`] characters. +fn first_sentence(description: &str) -> String { + let sentence = description.split_once(". ").map_or(description, |(first, _)| first); + let sentence = sentence.trim_end_matches('.'); + let cut: String = sentence.chars().take(TITLE_SENTENCE).collect(); + if cut.len() < sentence.len() { format!("{cut}...") } else { cut } +} + /// About how many lines `text` takes at `width` narrow characters a line, on the safe side: the font /// is not known here, so a wide character (CJK, emoji and the like) counts as two. fn lines(text: &str, width: usize) -> usize { @@ -494,9 +531,39 @@ mod tests { reaction: None, target_text: None, target_cut: false, + description: None, + arguments: Vec::new(), } } + #[test] + fn module_tool_prompts_show_the_description_and_arguments() { + let mut join = preview("a1", Kind::ModuleTool("teams_join_captions".into())); + join.text = None; + join.description = Some("Join the meeting for its live captions. open_mess joins as a second device of your account.".into()); + let prompt = Prompt::new(&join, Lang::En); + assert_eq!(prompt.title, "open_mess: approve - Join the meeting for its live captions"); + assert_eq!( + prompt.content, + "Join the meeting for its live captions. open_mess joins as a second device of your account.\n\nThe approval is for the conversation, not only for the people in it now." + ); + // The participants are those in it now; long content goes to the dialog. + assert_eq!(prompt.toast().context, "Ann (teams:ann) - Project\nIn it now: Pat, Sam"); + assert_eq!(prompt.toast().buttons, show_all(Lang::En)); + assert!(prompt.dialog().unwrap().ends_with(&prompt.content)); + // Every argument, in full. + join.arguments = vec!["language: pl-pl".into(), "limit: 5".into()]; + let prompt = Prompt::new(&join, Lang::Pl); + assert_eq!(prompt.title, "open_mess: zatwierdź - Join the meeting for its live captions"); + assert!( + prompt.content.contains("account.\n\n- language: pl-pl\n- limit: 5\n\nZgoda dotyczy rozmowy, nie tylko osób, które są w niej teraz.") + ); + assert_eq!(prompt.toast().context, "Ann (teams:ann) - Project\nTeraz w rozmowie: Pat, Sam"); + // A title shows one sentence, at most 100 characters. + join.description = Some(format!("{}.", "x".repeat(150))); + assert_eq!(Prompt::new(&join, Lang::En).title, format!("open_mess: approve - {}...", "x".repeat(100))); + } + #[test] fn toast_text_is_truncated_with_show_all() { let mut send = preview("a1", Kind::Send); diff --git a/crates/open-mess/src/daemon.rs b/crates/open-mess/src/daemon.rs index 1de7a57..1c3265e 100644 --- a/crates/open-mess/src/daemon.rs +++ b/crates/open-mess/src/daemon.rs @@ -12,6 +12,7 @@ use open_mess_host::{Engine, ModuleContext, Net, SignInPrompts}; use tokio::net::TcpListener; use tokio::runtime::Runtime; use tokio::sync::{Notify, mpsc, oneshot}; +use tracing_subscriber::filter::{LevelFilter, Targets}; use crate::accounts::Accounts; use crate::events::Bus; @@ -40,6 +41,18 @@ pub const LAST_START_ERROR_FILE: &str = "last-start-error.txt"; /// tray menu. The tray and the configuration window take the main thread; the API runs on the /// runtime's threads. pub fn run() -> Result<(), Error> { + // Diagnostics only: OPEN_MESS_LOG (error, warn, info, debug or trace) prints the daemon's and + // the modules' logs to stderr. Logs carry states, codes and counts, never tokens or message + // contents (spec 8.4). + if let Some(level) = std::env::var("OPEN_MESS_LOG").ok().and_then(|level| level.parse::().ok()) { + use tracing_subscriber::layer::SubscriberExt as _; + use tracing_subscriber::util::SubscriberInitExt as _; + tracing_subscriber::registry() + .with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr).with_ansi(false)) + .with(log_targets(level)) + .try_init() + .ok(); + } let started = Instant::now(); let dir = paths::data_dir()?; let error_file = dir.join(LAST_START_ERROR_FILE); @@ -76,6 +89,8 @@ pub fn run() -> Result<(), Error> { let exit = Arc::clone(&ui.exit); let registry = Arc::new(registry); runtime.spawn(Arc::clone(®istry).watch_tools()); + runtime.spawn(Arc::clone(&ui.accounts).restore_after_crashes()); + let accounts = Arc::clone(&ui.accounts); if let Some((paths, launcher)) = update.filter(|_| probation) { let activity = Activity { gate: ui.gate.clone(), @@ -113,6 +128,9 @@ pub fn run() -> Result<(), Error> { runtime.block_on(window.close()); runtime.block_on(until_stopped(ended, exit)); } + // Before the modules go with the process: what others see of their sessions ends (spec + // 2026-10-29-live-captions, 4.3). + runtime.block_on(accounts.end_sessions(&accounts.modules().handles())); let _ = stop.send(()); runtime.block_on(server).map_err(|err| Error::Io(std::io::Error::other(err)))? } @@ -129,6 +147,12 @@ async fn until_stopped(ended: Arc, exit: Arc) { /// An update's folders and what starts its helper. type Update = (Paths, Arc); +/// What `OPEN_MESS_LOG` prints: open_mess's own targets only (`open_mess` and `open_mess_host`, by +/// prefix). A dependency's debug log, like rmcp's, prints whole tool results (Codex review of 0cff6c1). +fn log_targets(level: LevelFilter) -> Targets { + Targets::new().with_target("open_mess", level) +} + /// None where updates do not install. #[cfg(windows)] fn update() -> Result, Error> { @@ -283,3 +307,22 @@ async fn ctrl_c() { std::future::pending::<()>().await; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn diagnostics_print_only_open_mess_logs() { + let targets = log_targets(LevelFilter::DEBUG); + let debug = LevelFilter::DEBUG.into_level().unwrap(); + let error = LevelFilter::ERROR.into_level().unwrap(); + let trace = LevelFilter::TRACE.into_level().unwrap(); + assert!(targets.would_enable("open_mess::daemon", &debug)); + assert!(targets.would_enable("open_mess_host::bindings", &debug)); + assert!(!targets.would_enable("open_mess_host::bindings", &trace)); + // rmcp logs whole tool results at debug; not even its errors are printed. + assert!(!targets.would_enable("rmcp::service", &error)); + assert!(!targets.would_enable("hyper", &error)); + } +} diff --git a/crates/open-mess/src/modules.rs b/crates/open-mess/src/modules.rs index e7fde56..57d579d 100644 --- a/crates/open-mess/src/modules.rs +++ b/crates/open-mess/src/modules.rs @@ -199,12 +199,15 @@ impl Modules { /// until the user turns it off or approves a file that replaces it: a new file waiting for /// approval, a file that is gone or one that does not load (still being written, say) leave it /// running (spec 8.3). A module stopped after repeated crashes starts again only when `restart` - /// names it. Returns the modules that stopped, started or were replaced. - pub(crate) fn adopt(&self, store: &Store, found: Found, restart: Option<&str>) -> Result, Error> { + /// names it. Returns the modules that stopped, started or were replaced, and the handles of the + /// loaded versions that stopped: the caller ends their sessions before it drops them (spec + /// 2026-10-29-live-captions, 4.3). + pub(crate) fn adopt(&self, store: &Store, found: Found, restart: Option<&str>) -> Result<(Vec, Vec), Error> { let mut fresh = decide(store, found)?; let mut entries = self.write(); let mut changed = BTreeSet::new(); let mut kept = BTreeSet::new(); + let mut stopped = Vec::new(); for old in entries.drain(..).filter(|old| old.handle.is_some()) { let name = old.info.name.clone(); let enabled = Row::get(store.conn(), &name)?.is_some_and(|row| row.enabled); @@ -222,6 +225,7 @@ impl Modules { }; if !keep { changed.insert(name); + stopped.extend(old.handle); continue; } kept.insert(name); @@ -244,7 +248,7 @@ impl Modules { *entries = fresh; drop(entries); self.changes.send_replace(()); - Ok(changed.into_iter().collect()) + Ok((changed.into_iter().collect(), stopped)) } } @@ -329,10 +333,11 @@ pub(crate) async fn inspect(engine: &Engine, path: &Path) -> Result bool { - let without_version = |manifest: &Manifest| { - let mut json = canonical(manifest); +/// Whether two manifests, as [`canonical`] gives them, differ at most in `version` (spec +/// 2026-10-27-self-update, 7.4). +pub(crate) fn version_only_change(a: &Value, b: &Value) -> bool { + let without_version = |json: &Value| { + let mut json = json.clone(); if let Some(fields) = json.as_object_mut() { fields.remove("version"); } @@ -341,6 +346,25 @@ pub(crate) fn version_only_change(a: &Manifest, b: &Manifest) -> bool { without_version(a) == without_version(b) } +/// The module whose approved file is the one at `path`, and the manifest approved with it (as +/// [`canonical`] gives it), without loading the file: for a file this daemon's host cannot load, +/// one built for an older module contract (spec 2026-10-27-self-update, 7.4). None unless the file +/// is a module's approved file and its approved manifest was remembered. +pub(crate) fn approved_file(conn: &Connection, path: &Path) -> Result, Error> { + let Ok(bytes) = read_capped(path) else { return Ok(None) }; + let approved = conn + .query_row("SELECT name, approved_manifest_sha256 FROM modules WHERE approved_file_sha256 = ?1", [sha256_hex(&bytes)], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)) + }) + .optional()?; + let Some((name, Some(manifest_sha256))) = approved else { return Ok(None) }; + let remembered: Option = + conn.query_row("SELECT value FROM config WHERE key = ?1", [approved_manifest_key(&name)], |row| row.get(0)).optional()?; + // The remembered manifest is the approved one only when its hash is the approved hash. + let manifest = remembered.filter(|json| sha256_hex(json.as_bytes()) == manifest_sha256).and_then(|json| serde_json::from_str(&json).ok()); + Ok(manifest.map(|manifest| (name, manifest))) +} + /// Whether this is the file and manifest the user approved for its module. pub(crate) fn is_approved(conn: &Connection, inspected: &Inspected) -> Result { Ok(Row::get(conn, &inspected.manifest.name)?.is_some_and(|row| { @@ -501,7 +525,7 @@ pub fn canonical(manifest: &Manifest) -> Value { .iter() .map(|tool| json!({"description": tool.description, "input_schema": tool.input_schema, "name": tool.name, "writes": tool.writes})) .collect(); - json!({ + let mut canonical = json!({ "extra_tools": tools, "hosts": manifest.hosts, "name": manifest.name, @@ -509,7 +533,13 @@ pub fn canonical(manifest: &Manifest) -> Value { "sign_in_hosts": manifest.sign_in_hosts, "version": manifest.version, "wam": manifest.wam.as_ref().map(|wam| json!({"client_id": wam.client_id, "scopes": wam.scopes})), - }) + }); + // Only when present, so the hash of a manifest without media access stays what the user + // approved before modules could ask for it. + if let (Some(media), Some(object)) = (&manifest.media, canonical.as_object_mut()) { + object.insert("media".to_string(), json!({"peers": media.peers})); + } + canonical } fn approved_manifest_key(name: &str) -> String { @@ -571,4 +601,23 @@ mod tests { std::fs::File::create(&path).unwrap().set_len(16).unwrap(); assert_eq!(read_capped(&path).unwrap().len(), 16); } + + #[test] + fn media_access_is_approved_with_the_manifest_only_when_present() { + let without = Manifest { + name: "m".into(), + version: "1".into(), + platform: "test".into(), + hosts: Vec::new(), + sign_in_hosts: Vec::new(), + wam: None, + extra_tools: Vec::new(), + media: None, + }; + // A manifest without media access hashes as it did before modules could ask for it. + assert!(canonical(&without).get("media").is_none()); + let with = Manifest { media: Some(open_mess_host::MediaAccess { peers: vec!["52.112.0.0/14".into()] }), ..without.clone() }; + assert_eq!(canonical(&with)["media"]["peers"][0], "52.112.0.0/14"); + assert!(!version_only_change(&canonical(&with), &canonical(&without))); + } } diff --git a/crates/open-mess/src/pending.rs b/crates/open-mess/src/pending.rs index 230118b..0d3093d 100644 --- a/crates/open-mess/src/pending.rs +++ b/crates/open-mess/src/pending.rs @@ -9,21 +9,26 @@ //! does a write permission set to `never` meanwhile. //! An action that may have reached the platform without an answer is `uncertain` and never run //! again by itself (spec 8.5, R7). Nothing here logs or returns message text. +//! +//! A module's own write tool makes an action too (spec 2026-10-29-live-captions, 5.1). It always +//! waits for the user, `autonomous` or not, and its approval binds to the conversation and its +//! title (a meeting's participants change while it runs) and to the tool as its module declared it: +//! a module replaced meanwhile runs only the very tool the approval showed. use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use open_mess_host::limits::CALL_TIMEOUT; -use open_mess_host::{HostError, ModuleError, ModuleHandle, Target}; +use open_mess_host::{HostError, ModuleError, ModuleHandle, Target, ToolDecl}; use rusqlite::OptionalExtension; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use tokio::sync::watch; use crate::Error; -use crate::accounts::{Account, Accounts}; +use crate::accounts::{Account, Accounts, RunningWrite}; use crate::permissions::{self, Write}; -use crate::tools::{ToolError, find, module, within}; +use crate::tools::{ToolError, extra, find, module, within}; use crate::update::probation; /// How long an action waits for the user's approval. @@ -41,42 +46,66 @@ const RETENTION_SWEEP: Duration = Duration::from_secs(24 * 60 * 60); const MAX_NAME: usize = 256; const MAX_TEXT: usize = 28_000; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum Kind { Send, Edit, React, + /// A module's own write tool, by its full name (`teams_join_captions`). + ModuleTool(String), } +/// The `kind` column's mark of a module tool's action: `module:`. +const MODULE_TOOL: &str = "module:"; + impl Kind { - pub fn name(self) -> &'static str { + /// What the `pending` tool and the window call it. + pub fn name(&self) -> &str { match self { Kind::Send => "send", Kind::Edit => "edit", Kind::React => "react", + Kind::ModuleTool(name) => name, } } - /// The write tool (and module tool) that makes it. + /// The core write tool that makes it. pub fn for_tool(tool: &str) -> Option { [Kind::Send, Kind::Edit, Kind::React].into_iter().find(|kind| kind.tool() == tool) } - fn tool(self) -> &'static str { + /// The module's tool that runs it. + fn tool(&self) -> &str { match self { Kind::Send => "send", Kind::Edit => "edit_message", Kind::React => "react", + Kind::ModuleTool(name) => name, } } /// The message the action is about: the one replied to, edited or reacted to. - fn message(self, args: &Value) -> Option<&str> { - args.get(if self == Kind::Send { "reply_to" } else { "message" }).and_then(Value::as_str) + fn message<'a>(&self, args: &'a Value) -> Option<&'a str> { + let key = match self { + Kind::Send => "reply_to", + Kind::Edit | Kind::React => "message", + Kind::ModuleTool(_) => return None, + }; + args.get(key).and_then(Value::as_str) } - fn parse(name: &str) -> Result { - [Kind::Send, Kind::Edit, Kind::React].into_iter().find(|kind| kind.name() == name).ok_or_else(|| bad_row("kind", name)) + fn column(&self) -> String { + match self { + Kind::ModuleTool(name) => format!("{MODULE_TOOL}{name}"), + kind => kind.name().to_string(), + } + } + + fn parse(column: &str) -> Result { + if let Some(name) = column.strip_prefix(MODULE_TOOL) { + return Ok(Kind::ModuleTool(name.to_string())); + } + [Kind::Send, Kind::Edit, Kind::React].into_iter().find(|kind| kind.name() == column).ok_or_else(|| bad_row("kind", column)) } } @@ -140,6 +169,10 @@ pub struct Preview { pub target_text: Option, /// That text was longer than an approval shows, so this action can only be rejected. pub target_cut: bool, + /// A module tool's description, from the manifest the user approved, in full. + pub description: Option, + /// A module tool's arguments but `conversation`, each as `key: value`, in full. + pub arguments: Vec, } // Hand-written so that a `{:?}` never prints names or text. @@ -210,15 +243,19 @@ impl Pending { self.changed.subscribe() } - /// Creates the action a write tool asked for, under the account's or conversation's write + /// Creates the action a core write tool asked for, under the account's or conversation's write /// permission: run at once, left waiting for approval, or refused. pub async fn create(&self, kind: Kind, account: &str, conversation: &str, args: Value) -> Result { + // A module tool's action never runs at once: [`Pending::create_tool`] makes it. + if let Kind::ModuleTool(name) = &kind { + return Err(ToolError::UnknownTool(name.clone())); + } let account = find(&self.accounts, account)?; let write = permissions::effective(&self.accounts.store(), &account.id, Some(conversation)).map_err(failed)?.write; match write { Write::Never => Err(ToolError::NotAllowed("writing to this conversation is not allowed".into())), Write::Autonomous => { - let action = self.insert(&account, conversation, kind, &args, "", State::Approved, None)?; + let action = self.insert(&account, conversation, &kind, &args, "", State::Approved, None)?; let (state, detail) = self.execute(&action).await.map_err(failed)?; Ok(Outcome { action, state, detail }) } @@ -228,14 +265,46 @@ impl Pending { } let handle = module(&self.accounts, &account)?; let target = within(&account.module, handle.describe_target(&account.module_account, conversation, kind.message(&args))).await?; - let action = self.insert(&account, conversation, kind, &args, &fingerprint(&target), State::Waiting, Some(&handle))?; - self.previews().push(preview(&action, kind, &account, &args, target)); + let action = self.insert(&account, conversation, &kind, &args, &fingerprint(&target), State::Waiting, Some(&handle))?; + self.previews().push(preview(&action, kind, &account, &args, target, None)); self.changed.send_replace(()); Ok(Outcome { action, state: State::Waiting, detail: None }) } } } + /// Creates the action a module's write tool `name`, declared as `decl`, asked for: left waiting + /// for the user's approval under `confirm` and `autonomous` alike, refused under `never`. + pub(crate) async fn create_tool( + &self, + name: &str, + decl: &ToolDecl, + account: &str, + conversation: &str, + args: Value, + ) -> Result { + let account = find(&self.accounts, account)?; + if permissions::effective(&self.accounts.store(), &account.id, Some(conversation)).map_err(failed)?.write == Write::Never { + return Err(ToolError::NotAllowed("writing to this conversation is not allowed".into())); + } + if self.count_waiting(&account.id)? >= MAX_WAITING { + return Err(too_many()); + } + let handle = module(&self.accounts, &account)?; + let target = within(&account.module, handle.describe_target(&account.module_account, conversation, None)).await?; + // The modules may have started to change while the module described the target: from then + // on no action is made (a swap meanwhile is refused by `insert` too). + if self.accounts.module_writes().blocked() { + return Err(ToolError::Failed(MODULES_CHANGING.into())); + } + let kind = Kind::ModuleTool(name.into()); + let fingerprint = tool_fingerprint(conversation, &target, decl); + let action = self.insert(&account, conversation, &kind, &args, &fingerprint, State::Waiting, Some(&handle))?; + self.previews().push(preview(&action, kind, &account, &args, target, Some(decl))); + self.changed.send_replace(()); + Ok(Outcome { action, state: State::Waiting, detail: None }) + } + /// The actions in `state`, over all accounts. pub fn count(&self, state: State) -> Result { let count: i64 = self.accounts.store().conn().query_row("SELECT COUNT(*) FROM pending WHERE state = ?1", [state.name()], |row| row.get(0))?; @@ -260,7 +329,7 @@ impl Pending { &self, account: &Account, conversation: &str, - kind: Kind, + kind: &Kind, args: &Value, fingerprint: &str, state: State, @@ -281,7 +350,17 @@ impl Pending { "INSERT INTO pending (id, account, conversation, kind, args, fingerprint, state, created_ms) SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8 WHERE ?7 <> 'waiting' OR (SELECT COUNT(*) FROM pending WHERE account = ?2 AND state = 'waiting') < ?9", - rusqlite::params![action, account.id, conversation, kind.name(), args.to_string(), fingerprint, state.name(), now_ms(), MAX_WAITING], + rusqlite::params![ + action, + account.id, + conversation, + kind.column(), + args.to_string(), + fingerprint, + state.name(), + now_ms(), + MAX_WAITING + ], ) .map_err(failed)?; if inserted == 0 { @@ -334,7 +413,10 @@ impl Pending { .query_map(rusqlite::params![account, state.map(State::name), limit], |row| { let mut item = Map::new(); for (index, key) in ["action", "account", "conversation", "kind", "state"].into_iter().enumerate() { - item.insert(key.into(), row.get::<_, String>(index)?.into()); + let value: String = row.get(index)?; + // A module tool's action by the tool's name, as the agent called it. + let value = if key == "kind" { value.strip_prefix(MODULE_TOOL).map(str::to_string).unwrap_or(value) } else { value }; + item.insert(key.into(), value.into()); } item.insert("created".into(), time(row.get(5)?).into()); if let Some(decided) = row.get::<_, Option>(6)? { @@ -395,8 +477,16 @@ impl Pending { let args = serde_json::from_str::(&row.args).unwrap_or_default(); match describe(&self.accounts, &account, &row.conversation, kind.message(&args)).await { Err(err) => return (State::Failed, Some(format!("cannot check the target: {err}"))), - Ok(target) if fingerprint(&target) != row.fingerprint => return (State::Voided, Some(VOIDED.into())), - Ok(_) => {} + Ok(target) => match &kind { + Kind::ModuleTool(_) + if row.fingerprint.split_once('/').map(|(title, _)| title) != Some(&title_fingerprint(&row.conversation, &target)) => + { + return (State::Voided, Some(TITLE_CHANGED.into())); + } + Kind::ModuleTool(_) => {} + _ if fingerprint(&target) != row.fingerprint => return (State::Voided, Some(VOIDED.into())), + _ => {} + }, } } // The user may have turned writing off while the action waited. @@ -405,8 +495,15 @@ impl Pending { Ok(effective) if effective.write == Write::Never => return (State::Voided, Some(NOT_ALLOWED.into())), Ok(_) => {} } + let (handle, tool, _running) = match &kind { + Kind::ModuleTool(name) => match self.module_tool(&account, name, &row.fingerprint) { + Ok((handle, tool, running)) => (handle, tool, Some(running)), + Err(why) => return (State::Failed, Some(why)), + }, + kind => (handle, kind.tool().to_string(), None), + }; let module_name = &account.module; - match tokio::time::timeout(CALL_TIMEOUT, handle.call(&account.module_account, kind.tool(), &row.args)).await { + match tokio::time::timeout(CALL_TIMEOUT, handle.call(&account.module_account, &tool, &row.args)).await { // What the module returns besides success is not needed, and never shown. Ok(Ok(_)) => (State::Done, None), Ok(Err(HostError::Module(ModuleError::Uncertain(why)))) => (State::Uncertain, Some(format!("module {module_name}: {why}; {CHECK}"))), @@ -419,6 +516,21 @@ impl Pending { } } + /// The module and the tool an approved action of module tool `name` calls, its call counted + /// while the guard lives: none while the modules change (spec 2026-10-29-live-captions, 4.3), + /// and only the very tool the approval showed. + fn module_tool(&self, account: &Account, name: &str, fingerprint: &str) -> Result<(ModuleHandle, String, RunningWrite<'_>), String> { + let running = self.accounts.module_writes().start().ok_or(MODULES_CHANGING)?; + // Looked up again under the guard: no swap of the module comes between this and the call. + let handle = module(&self.accounts, account).map_err(|err| err.to_string())?; + let manifest = handle.manifest().map_err(|err| err.to_string())?; + let decl = manifest.extra_tools.into_iter().find(|decl| extra::full_name(&account.module, &decl.name) == name); + match decl { + Some(decl) if fingerprint.split_once('/').is_some_and(|(_, approved)| approved == declared(&decl)) => Ok((handle, decl.name, running)), + _ => Err(TOOL_CHANGED.into()), + } + } + /// Expires the actions that waited longer than [`APPROVAL_TTL`], and forgets the previews of /// actions that are no longer waiting (an account removed meanwhile). pub fn expire_due(&self) -> Result<(), Error> { @@ -472,6 +584,10 @@ struct Row { } const VOIDED: &str = "the participants or the target message changed after the approval; nothing was sent"; +const TITLE_CHANGED: &str = "the conversation's title changed after the approval; nothing was done"; +const TOOL_CHANGED: &str = "the module's tool changed after the approval; nothing was done"; +/// Why a module write tool is refused while an update's phase A or a scan of the modules folder runs. +pub(crate) const MODULES_CHANGING: &str = "the modules are being updated or reloaded; nothing was done - try again shortly"; const NOT_ALLOWED: &str = "writing to this conversation is not allowed any more; nothing was sent"; const CHECK: &str = "it may have reached the platform - check the conversation before trying again"; @@ -507,6 +623,21 @@ async fn describe(accounts: &Accounts, account: &Account, conversation: &str, me within(&account.module, handle.describe_target(&account.module_account, conversation, message)).await } +/// What a module tool's approval binds to: the conversation and its title, then, after a `/`, the +/// tool as the module declared it. Not the participants: a meeting's change while it runs, and +/// they don't say whose words a tool reads (spec 2026-10-29-live-captions, 5.1, R6). +fn tool_fingerprint(conversation: &str, target: &Target, decl: &ToolDecl) -> String { + format!("{}/{}", title_fingerprint(conversation, target), declared(decl)) +} + +fn title_fingerprint(conversation: &str, target: &Target) -> String { + crate::modules::sha256_hex(json!([conversation, target.conversation_title]).to_string().as_bytes()) +} + +fn declared(decl: &ToolDecl) -> String { + crate::modules::sha256_hex(json!([decl.name, decl.description, decl.input_schema, decl.writes]).to_string().as_bytes()) +} + /// SHA-256 of the sorted participant ids and the target text: what an approval binds to. fn fingerprint(target: &Target) -> String { let mut ids: Vec<&str> = target.participants.iter().map(|participant| participant.id.as_str()).collect(); @@ -518,8 +649,15 @@ fn cut(text: &str, max: usize) -> String { text.chars().take(max).collect() } -fn preview(action: &str, kind: Kind, account: &Account, args: &Value, target: Target) -> Preview { - let arg = |key: &str| args.get(key).and_then(Value::as_str).map(str::to_owned); +/// What the approval shows; `tool` is a module tool's declaration. +fn preview(action: &str, kind: Kind, account: &Account, args: &Value, target: Target, tool: Option<&ToolDecl>) -> Preview { + let arg = |key: &str| args.get(key).and_then(Value::as_str).map(str::to_owned).filter(|_| tool.is_none()); + // Each in full: they fit the 16 KiB of a module tool's arguments (extra.rs). + let arguments = args.as_object().into_iter().flatten().filter(|(key, _)| tool.is_some() && *key != "conversation"); + let arguments = arguments.map(|(key, value)| match value { + Value::String(text) => format!("{key}: {text}"), + other => format!("{key}: {other}"), + }); Preview { action: action.into(), kind, @@ -530,5 +668,7 @@ fn preview(action: &str, kind: Kind, account: &Account, args: &Value, target: Ta reaction: arg("reaction"), target_cut: target.target_text.as_deref().is_some_and(|text| text.chars().count() > MAX_TEXT), target_text: target.target_text.as_deref().map(|text| cut(text, MAX_TEXT)), + description: tool.map(|decl| decl.description.clone()), + arguments: arguments.collect(), } } diff --git a/crates/open-mess/src/tools/extra.rs b/crates/open-mess/src/tools/extra.rs index 27f4436..71b4875 100644 --- a/crates/open-mess/src/tools/extra.rs +++ b/crates/open-mess/src/tools/extra.rs @@ -1,6 +1,8 @@ //! The modules' own tools (manifest `extra_tools`, spec 2026-10-28-meetings-transcripts 5.1): which //! of them both front ends list, as `_`, and the arguments the core checks before the -//! module gets them. The module checks the types and ranges of its own arguments. +//! module gets them. The module checks the types and ranges of its own arguments. A write tool +//! names the conversation it writes to and the account that writes (spec 2026-10-29-live-captions, +//! 5.1); its calls become pending actions. use std::collections::BTreeMap; @@ -33,7 +35,7 @@ pub(crate) struct Listed { /// The name both front ends use: `-` in the module name becomes `_`, as model APIs take only /// `[a-zA-Z0-9_-]{1,64}` and `.` is not among them. -fn full_name(module: &str, tool: &str) -> String { +pub(crate) fn full_name(module: &str, tool: &str) -> String { format!("{}_{tool}", module.replace('-', "_")) } @@ -99,12 +101,9 @@ fn plain_name(name: &str) -> bool { && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') } -/// A declared tool as the front ends list it: read tools only, plain names, and a schema the core -/// can check arguments against; `account` is the core's to add. +/// A declared tool as the front ends list it: plain names, a schema the core can check arguments +/// against, and a conversation required of a write tool; `account` is the core's to add. fn check(name: &str, tool: &open_mess_host::ToolDecl) -> Result { - if tool.writes { - return Err("not supported yet: module write tools".to_string()); - } if !plain_name(&tool.name) { return Err("the name must be 1-32 characters of a-z, 0-9 and _, starting with a letter".to_string()); } @@ -138,11 +137,23 @@ fn check(name: &str, tool: &open_mess_host::ToolDecl) -> Result } Some(_) => return Err("required is not a list".to_string()), }; - let account = - json!({"type": "string", "description": "Account id from the accounts tool; left out, the module's only account the agent may read."}); + if tool.writes && !required.iter().any(|key| key == "conversation") { + return Err("a module write tool must name its conversation".to_string()); + } + let account = if tool.writes { + // As for `send`: the core never guesses the account that writes. + json!({"type": "string", "description": "Account id from the accounts tool: the account that writes."}) + } else { + json!({"type": "string", "description": "Account id from the accounts tool; left out, the module's only account the agent may read."}) + }; if let Some(Value::Object(published)) = schema.get_mut("properties") { published.insert("account".to_string(), account); } + if tool.writes + && let Some(Value::Array(keys)) = schema.get_mut("required") + { + keys.push(json!("account")); + } schema.insert("additionalProperties".to_string(), json!(false)); let info = ToolInfo { name: name.to_string(), description: tool.description.clone(), input_schema: Value::Object(schema) }; Ok(Listed { info, properties, required }) @@ -187,6 +198,7 @@ mod tests { sign_in_hosts: Vec::new(), wam: None, extra_tools: tools, + media: None, }; ModuleInfo { name: name.into(), @@ -217,22 +229,25 @@ mod tests { #[test] fn only_fit_tools_of_active_modules_are_listed() { let writes = ToolDecl { writes: true, ..tool("poke", schema()) }; + let anywhere = ToolDecl { writes: true, ..tool("nudge", json!({"type": "object", "properties": {"conversation": {}}})) }; let modules = [ module( "my-mod", ModuleState::Active, - vec![tool("peek", schema()), writes, tool("Bad", schema()), tool("x".repeat(33).as_str(), schema())], + vec![tool("peek", schema()), writes, anywhere, tool("Bad", schema()), tool("x".repeat(33).as_str(), schema())], ), module("off", ModuleState::Disabled, vec![tool("peek", schema())]), ]; let listed = listed(&modules); - assert_eq!(listed.iter().map(|t| t.name.as_str()).collect::>(), ["my_mod_peek"]); - // `account` is added, other properties refused. + assert_eq!(listed.iter().map(|t| t.name.as_str()).collect::>(), ["my_mod_peek", "my_mod_poke"]); + // `account` is added, other properties refused; a write tool requires it. assert_eq!(listed[0].input_schema["properties"]["account"]["type"], "string"); assert_eq!(listed[0].input_schema["additionalProperties"], false); + assert_eq!(listed[0].input_schema["required"], json!(["conversation"])); + assert_eq!(listed[1].input_schema["required"], json!(["conversation", "account"])); let reasons = reasons(&modules, "my-mod"); assert_eq!(reasons.len(), 3, "{reasons:?}"); - assert!(reasons.iter().any(|(name, why)| name == "my_mod_poke" && why == "not supported yet: module write tools")); + assert!(reasons.iter().any(|(name, why)| name == "my_mod_nudge" && why == "a module write tool must name its conversation")); assert!(reasons.iter().any(|(name, why)| name == "my_mod_Bad" && why.contains("a-z"))); } diff --git a/crates/open-mess/src/tools/mod.rs b/crates/open-mess/src/tools/mod.rs index e80adaf..83728fb 100644 --- a/crates/open-mess/src/tools/mod.rs +++ b/crates/open-mess/src/tools/mod.rs @@ -7,8 +7,8 @@ //! (spec 8.6): an account or conversation the agent may not read is refused when named and left //! out of lists. The writes never reach a module from here: they become pending actions //! (pending.rs), which run under the write permission. Every call passes the [`Gate`], which an -//! update closes (spec 7.1). Active modules add their own read tools (extra.rs), under the same -//! permissions. +//! update closes (spec 7.1). Active modules add their own tools (extra.rs), under the same +//! permissions; their write tools make pending actions too. pub mod extra; pub mod shared; @@ -28,7 +28,7 @@ use tokio::task::JoinSet; use crate::accounts::{Account, Accounts, REMOVING}; use crate::modules::{ModuleInfo, ModuleState}; -use crate::pending::{Kind, Pending, State}; +use crate::pending::{self, Kind, Pending, State}; use crate::permissions; use crate::triggers::Counters; use crate::triggers::channel::Sessions; @@ -103,15 +103,16 @@ impl Gate { self.0.last_end_ms.load(Ordering::SeqCst) } - /// Takes a call of `tool`, counted while the guard lives, or refuses it. - fn enter(&self, tool: &str) -> Result { + /// Takes a call of a tool, a write tool if `writes`, counted while the guard lives, or refuses + /// it. + fn enter(&self, writes: bool) -> Result { // Counted before the mode is read: whoever closes the gate and then sees no call running // knows that every later call sees it closed. self.0.in_flight.fetch_add(1, Ordering::SeqCst); let call = InFlight(Arc::clone(&self.0)); match *self.0.mode.lock().unwrap_or_else(PoisonError::into_inner) { GateMode::Closed => Err(update_in_progress()), - GateMode::WritesBlocked if Kind::for_tool(tool).is_some() => Err(update_in_progress()), + GateMode::WritesBlocked if writes => Err(update_in_progress()), _ => Ok(call), } } @@ -278,7 +279,12 @@ impl Registry { /// Runs a tool. `args` is the arguments object. pub async fn call(&self, name: &str, args: Value) -> Result { - let _call = self.gate.enter(name)?; + // A module's write tool is known by its declaration, not by a list (spec + // 2026-10-29-live-captions, 5.1, R9). + let module_writes = + |accounts: &Arc| extra::find(&accounts.modules().list(), name).is_some_and(|tools| tools.iter().any(|tool| tool.decl.writes)); + let writes = Kind::for_tool(name).is_some() || self.accounts.as_ref().is_some_and(module_writes); + let _call = self.gate.enter(writes)?; self.run(name, args).await } @@ -287,10 +293,10 @@ impl Registry { no_params(&args)?; return self.status(); } - if let Some(accounts) = &self.accounts + if let (Some(accounts), Some(pending)) = (&self.accounts, &self.pending) && let Some(tool) = extra::find(&accounts.modules().list(), name) { - return call_extra(accounts, name, tool, args).await; + return call_extra(accounts, pending, name, tool, args).await; } let args = shared::validate(name, args)?; let (Some(accounts), Some(pending)) = (&self.accounts, &self.pending) else { @@ -488,8 +494,10 @@ fn finish(accounts: &Accounts, account: &Account, tool: &str, args: &Value, resu } /// Runs a module's own tool (spec 2026-10-28-meetings-transcripts, 5.1); `tools` are the -/// declarations of its full `name`, one when it is listed. -async fn call_extra(accounts: &Accounts, name: &str, mut tools: Vec, args: Value) -> Result { +/// declarations of its full `name`, one when it is listed. A write tool's call becomes a pending +/// action under the write permission, as `send`'s does, and never reaches the module from here +/// (spec 2026-10-29-live-captions, 5.1). +async fn call_extra(accounts: &Accounts, pending: &Pending, name: &str, mut tools: Vec, args: Value) -> Result { let (Some(tool), None) = (tools.pop(), tools.first()) else { return Err(ToolError::UnknownTool(name.into())) }; // A client that missed the change of the list learns why the tool is gone. if tool.state != ModuleState::Active { @@ -505,15 +513,26 @@ async fn call_extra(accounts: &Accounts, name: &str, mut tools: Vec return Err(ToolError::InvalidArgs("account is required".into())), None => only_readable(accounts, &tool.module)?, }; - let account = readable(accounts, &account.id, &module_args)?; + // A write is under the write permission only, as for `send` (spec 2026-10-29-live-captions, R3). + let account = if tool.decl.writes { account } else { readable(accounts, &account.id, &module_args)? }; let handle = module(accounts, &account)?; // The module may have been replaced since its tools were read: the one called must declare // this very tool, never one that writes now. if !handle.manifest().is_ok_and(|manifest| manifest.extra_tools.contains(&tool.decl)) { return Err(ToolError::Module(format!("module {} changed meanwhile; try again", tool.module))); } + if tool.decl.writes { + if accounts.module_writes().blocked() { + return Err(ToolError::Failed(pending::MODULES_CHANGING.into())); + } + // The schema makes `conversation` required and text. + let conversation = module_args.get("conversation").and_then(Value::as_str).unwrap_or_default().to_owned(); + let outcome = pending.create_tool(name, &tool.decl, &account.id, &conversation, module_args).await?; + return serde_json::to_value(outcome).map_err(failed); + } let json = within(&account.module, handle.call(&account.module_account, &tool.decl.name, &module_args.to_string())).await?; if json.len() > extra::MAX_RESULT { return Err(ToolError::Module(format!("module returned a result over {} MiB", extra::MAX_RESULT / 1024 / 1024))); diff --git a/crates/open-mess/src/ui/commands.rs b/crates/open-mess/src/ui/commands.rs index f4a36ca..5d6c82b 100644 --- a/crates/open-mess/src/ui/commands.rs +++ b/crates/open-mess/src/ui/commands.rs @@ -147,6 +147,8 @@ pub struct ModuleDto { pub detail: String, pub hosts: Vec, pub sign_in_hosts: Vec, + /// The address ranges of the module's media connections; empty without media access. + pub media: Vec, pub wam: Option, /// The module's own tools. pub tools: Vec, @@ -177,7 +179,7 @@ pub fn modules(state: &UiState) -> Result, String> { info.state, ModuleState::PendingReplacement | ModuleState::AwaitingApproval { reason: ApprovalReason::ManifestChanged | ApprovalReason::FileChanged } ); - let Manifest { hosts, sign_in_hosts, wam, extra_tools, .. } = info.manifest.clone().unwrap_or_else(|| empty_manifest(info)); + let Manifest { hosts, sign_in_hosts, wam, extra_tools, media, .. } = info.manifest.clone().unwrap_or_else(|| empty_manifest(info)); dtos.push(ModuleDto { name: info.name.clone(), file: info.file.clone(), @@ -187,6 +189,7 @@ pub fn modules(state: &UiState) -> Result, String> { detail: describe(&info.state, i18n::current()), hosts, sign_in_hosts, + media: media.map(|media| media.peers).unwrap_or_default(), wam: wam .map(|wam| WamDto { client_id: wam.client_id, scopes: wam.scopes.iter().map(|scope| scope_text(scope, i18n::current())).collect() }), tools: extra_tools.into_iter().map(|tool| ToolDto { name: tool.name, writes: tool.writes }).collect(), @@ -222,6 +225,7 @@ fn empty_manifest(info: &ModuleInfo) -> Manifest { sign_in_hosts: Vec::new(), wam: None, extra_tools: Vec::new(), + media: None, } } @@ -273,6 +277,8 @@ fn changes(new: &Value, old: &Value, lang: Lang) -> Vec { let (new_host, removed_host) = (lang.pick("nowy host logowania", "new sign-in host"), lang.pick("usunięty host logowania", "removed sign-in host")); list_changes(&mut changes, new_host, removed_host, field(new, "sign_in_hosts"), field(old, "sign_in_hosts")); + let (new_range, removed_range) = (lang.pick("nowy zakres mediów", "new media range"), lang.pick("usunięty zakres mediów", "removed media range")); + list_changes(&mut changes, new_range, removed_range, field(field(new, "media"), "peers"), field(field(old, "media"), "peers")); if field(new, "wam") != field(old, "wam") { let wam = |manifest: &Value| match field(manifest, "wam") { wam @ Value::Object(_) => format!("{} ({})", text_of(field(wam, "client_id")), strings(field(wam, "scopes")).join(", ")), @@ -473,8 +479,8 @@ pub fn clear_permission_override(state: &UiState, account: &str, conversation: & #[derive(Clone, Debug, Serialize)] pub struct PreviewDto { pub action: String, - /// `send`, `edit` or `react`. - pub kind: &'static str, + /// `send`, `edit` or `react`, or a module write tool's full name. + pub kind: String, /// The account's name and id. pub account: String, pub conversation: String, @@ -487,6 +493,10 @@ pub struct PreviewDto { pub target_text: Option, /// That message is longer than shown: the action can only be rejected. pub target_cut: bool, + /// A module write tool's description, from its approved manifest. + pub description: Option, + /// A module write tool's arguments but `conversation`, each as `key: value`. + pub arguments: Vec, } /// Why an action whose target message could not be shown in full is not approved. @@ -507,7 +517,7 @@ pub fn waiting(state: &UiState) -> Vec { .iter() .map(|preview| PreviewDto { action: preview.action.clone(), - kind: preview.kind.name(), + kind: preview.kind.name().to_string(), account: approval::clean(&preview.account), conversation: approval::clean(&preview.conversation_title), participants: preview.participants.iter().map(|name| approval::clean(name)).collect(), @@ -515,6 +525,8 @@ pub fn waiting(state: &UiState) -> Vec { reaction: clean(&preview.reaction), target_text: clean(&preview.target_text), target_cut: preview.target_cut, + description: clean(&preview.description), + arguments: preview.arguments.iter().map(|argument| approval::clean(argument)).collect(), }) .collect() } @@ -939,6 +951,15 @@ mod tests { assert!(autonomous_rule(Lang::En).contains("\"autonomously\"")); } + #[test] + fn a_media_range_is_a_change_to_approve() { + let old = serde_json::json!({"hosts": []}); + let new = serde_json::json!({"hosts": [], "media": {"peers": ["52.112.0.0/14"]}}); + assert_eq!(changes(&new, &old, Lang::Pl), ["nowy zakres mediów: 52.112.0.0/14"]); + assert_eq!(changes(&new, &old, Lang::En), ["new media range: 52.112.0.0/14"]); + assert_eq!(changes(&old, &new, Lang::En), ["removed media range: 52.112.0.0/14"]); + } + #[test] fn only_an_update_s_names_are_placed_beside() { for file in ["teams-1.2.3.wasm", "teams-1.2.3-4.wasm", "open-mess-teams-10.0.1-100.wasm"] { diff --git a/crates/open-mess/src/update/apply.rs b/crates/open-mess/src/update/apply.rs index e2bd8fa..49242ed 100644 --- a/crates/open-mess/src/update/apply.rs +++ b/crates/open-mess/src/update/apply.rs @@ -580,13 +580,23 @@ fn place_modules(paths: &Paths, pending: &mut PendingFile, manifest: &Manifest) // Reading a manifest runs the module under the host, which needs a runtime. let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?; runtime.block_on(async { + // Each approved file: its module's name and approved manifest. let mut approved = Vec::new(); for path in modules::module_files(&dir)? { - // A file that is no module, or not the approved one, is nobody's predecessor. - if let Ok(found) = modules::inspect(&engine, &path).await - && modules::is_approved(&conn, &found).map_err(other)? - { - approved.push((path, found)); + match modules::inspect(&engine, &path).await { + // A file that is no module, or not the approved one, is nobody's predecessor. + Ok(found) => { + if modules::is_approved(&conn, &found).map_err(other)? { + approved.push((path, found.manifest.name.clone(), modules::canonical(&found.manifest))); + } + } + // One this release's host cannot load, built for an older module contract, is + // known by its approval. + Err(_) => { + if let Some((name, manifest)) = modules::approved_file(&conn, &path).map_err(other)? { + approved.push((path, name, manifest)); + } + } } } for entry in entries { @@ -596,8 +606,9 @@ fn place_modules(paths: &Paths, pending: &mut PendingFile, manifest: &Manifest) if new.file_sha256 != entry.sha256 { return Err(UpdateError::Rejected(format!("{} does not match the release manifest", entry.name))); } - let predecessor = approved.iter().find(|(_, old)| old.manifest.name == new.manifest.name); - if let Some((path, _)) = predecessor.filter(|(_, old)| modules::version_only_change(&old.manifest, &new.manifest)) { + let predecessor = approved.iter().find(|(_, name, _)| *name == new.manifest.name); + let new_manifest = modules::canonical(&new.manifest); + if let Some((path, _, _)) = predecessor.filter(|(_, _, old)| modules::version_only_change(old, &new_manifest)) { // Copied next to it and renamed over it: atomic within the folder. pending.replaced.push(path.clone()); let temp = with_suffix(path, ".new"); diff --git a/crates/open-mess/src/update/service.rs b/crates/open-mess/src/update/service.rs index 0aa663c..dc1bbcd 100644 --- a/crates/open-mess/src/update/service.rs +++ b/crates/open-mess/src/update/service.rs @@ -489,6 +489,12 @@ impl UpdateService { async fn run_install(self, downloaded: Downloaded) { let setup = &self.0.setup; let activity = Activity { modules: setup.accounts.modules().handles(), ..setup.activity.clone() }; + // Before phase A pauses the modules' events: what others see of the sessions ends now, and + // agents join again after the update (spec 2026-10-29-live-captions, 4.3, R8). Until the + // update ends or is cancelled, no module write tool runs, so no join gets in after this. + let _block = setup.accounts.module_writes().block(); + setup.accounts.module_writes().finished().await; + setup.accounts.end_sessions(&activity.modules).await; let cancel = self.0.cancel.subscribe(); // Both phases resume everything themselves when cancelled. if finish_work(&activity, &cancel).await.is_err() { diff --git a/crates/open-mess/tests/end_sessions.rs b/crates/open-mess/tests/end_sessions.rs new file mode 100644 index 0000000..e6037f1 --- /dev/null +++ b/crates/open-mess/tests/end_sessions.rs @@ -0,0 +1,67 @@ +//! What others see of a module's sessions ends before the module goes (spec +//! 2026-10-29-live-captions, 4.3): `end-sessions` for every account when the daemon stops, an +//! update pauses the modules or a scan drops a module, and an immediate `restore` after a crash so a +//! new instance starts. The test-echo module records both exports in its storage; its tool `ended` +//! returns them. + +// clippy.toml allows unwrap in #[test] functions only; the helpers here are test code too. +#![allow(clippy::unwrap_used)] + +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use open_mess::modules; +use open_mess_host::ModuleHandle; +use serde_json::{Value, json}; + +async fn ended(handle: &ModuleHandle) -> Value { + serde_json::from_str(&handle.call("a", "ended", "{}").await.unwrap()).unwrap() +} + +fn echo(d: &common::Daemon) -> ModuleHandle { + d.accounts.modules().handle("test-echo").unwrap() +} + +#[tokio::test] +async fn every_account_of_every_module_ends_its_sessions() { + let d = common::daemon(&[common::ECHO]).await; + d.accounts.add("test-echo", common::info("a")).unwrap(); + d.accounts.add("test-echo", common::info("b")).unwrap(); + d.accounts.end_sessions(&d.accounts.modules().handles()).await; + let mut accounts: Vec = serde_json::from_value(ended(&echo(&d)).await["ended"].clone()).unwrap(); + accounts.sort(); + assert_eq!(accounts, ["a", "b"]); +} + +#[tokio::test] +async fn a_scan_that_turns_a_module_off_ends_its_sessions_first() { + let d = common::daemon(&[common::ECHO]).await; + d.accounts.add("test-echo", common::info("a")).unwrap(); + let store = common::store(d.dir.path()); + modules::set_enabled(&store, "test-echo", false).unwrap(); + assert_eq!(d.accounts.rescan_modules(None).await.unwrap(), ["test-echo"]); + assert!(d.accounts.modules().handle("test-echo").is_none()); + // The version that ran ended the account's sessions before it went; its storage tells. + modules::set_enabled(&store, "test-echo", true).unwrap(); + d.accounts.rescan_modules(None).await.unwrap(); + assert_eq!(ended(&echo(&d)).await["ended"], json!(["a"])); +} + +#[tokio::test] +async fn a_crash_restores_the_accounts_at_once() { + let d = common::daemon(&[common::ECHO]).await; + d.accounts.add("test-echo", common::info("a")).unwrap(); + tokio::spawn(Arc::clone(&d.accounts).restore_after_crashes()); + let handle = echo(&d); + assert!(handle.call("a", "trap", "{}").await.is_err()); + // A new instance came up by itself and ran `restore` for the account. + for _ in 0..50 { + if ended(&handle).await["restored"] == json!(["a"]) { + return; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + panic!("no restore after the crash: {}", ended(&handle).await); +} diff --git a/crates/open-mess/tests/pending.rs b/crates/open-mess/tests/pending.rs index b98bba1..5cd8d4c 100644 --- a/crates/open-mess/tests/pending.rs +++ b/crates/open-mess/tests/pending.rs @@ -76,7 +76,7 @@ async fn confirm_waits_then_runs_on_approve() { let waiting = pending.waiting(); assert_eq!(waiting.len(), 1); let preview = &waiting[0]; - assert_eq!((preview.action.as_str(), preview.kind), (outcome.action.as_str(), Kind::Send)); + assert_eq!((preview.action.as_str(), &preview.kind), (outcome.action.as_str(), &Kind::Send)); assert_eq!(preview.account, "User a (test-echo:a)"); assert_eq!(preview.conversation_title, "Echo c1"); assert_eq!(preview.participants, ["Pat"]); diff --git a/crates/open-mess/tests/shared_tools.rs b/crates/open-mess/tests/shared_tools.rs index d1a1149..da41b4e 100644 --- a/crates/open-mess/tests/shared_tools.rs +++ b/crates/open-mess/tests/shared_tools.rs @@ -277,14 +277,14 @@ fn conversations(result: &Value) -> Vec<&str> { async fn module_read_tools_are_listed_with_account() { let d = peeking(&[]).await; let listed: Vec<_> = d.registry.list().into_iter().filter(|tool| tool.name.starts_with("test_echo_")).collect(); - assert_eq!(listed.iter().map(|tool| tool.name.as_str()).collect::>(), ["test_echo_peek"]); + assert_eq!(listed.iter().map(|tool| tool.name.as_str()).collect::>(), ["test_echo_peek", "test_echo_poke"]); let schema = &listed[0].input_schema; assert_eq!((&schema["properties"]["account"]["type"], &schema["additionalProperties"]), (&json!("string"), &json!(false))); // The others say why they are not listed. let status = d.registry.call("status", json!({})).await.unwrap(); let not_listed = status["modules"][0]["tools_not_listed"].as_array().unwrap(); let reason = |name: &str| not_listed.iter().find(|tool| tool["tool"] == name).map(|tool| tool["reason"].as_str().unwrap().to_string()); - assert_eq!(reason("test_echo_poke").as_deref(), Some("not supported yet: module write tools")); + assert_eq!(reason("test_echo_nudge").as_deref(), Some("a module write tool must name its conversation")); assert!(reason("test_echo_Bad-Name").unwrap().contains("a-z")); assert!(reason("test_echo_with_account").unwrap().contains("account")); assert!(reason("test_echo_loose").unwrap().contains("other properties")); diff --git a/crates/open-mess/tests/ui.rs b/crates/open-mess/tests/ui.rs index 185d73c..4ed7b1d 100644 --- a/crates/open-mess/tests/ui.rs +++ b/crates/open-mess/tests/ui.rs @@ -99,6 +99,8 @@ async fn module_dto_lists_hosts_and_identity() { assert_eq!((teams.name.as_str(), teams.status, teams.detail.as_str()), ("teams", "awaiting-approval", "czeka na zatwierdzenie: nowy moduł")); assert!(teams.hosts.contains(&"*.sharepoint.com".to_string()), "{:?}", teams.hosts); assert_eq!(teams.sign_in_hosts, ["login.microsoftonline.com"]); + // Live captions' media peers are part of what the user approves (spec 2026-10-29-live-captions, 5.2). + assert_eq!(teams.media, ["52.112.0.0/14", "52.122.0.0/15", "2603:1063::/38"]); let wam = teams.wam.as_ref().unwrap(); assert_eq!(wam.client_id, "1fec8e78-bce4-4aaf-ab1b-5451cc387264"); assert_eq!(wam.scopes.len(), 5); @@ -269,7 +271,7 @@ async fn decide_from_window_updates_state() { let waiting = commands::waiting(&state); assert_eq!(waiting.len(), 1); let preview = &waiting[0]; - assert_eq!((preview.action.as_str(), preview.kind), (action.as_str(), "send")); + assert_eq!((preview.action.as_str(), preview.kind.as_str()), (action.as_str(), "send")); assert_eq!(preview.account, "User a (test-echo:a)"); assert_eq!(preview.conversation, "Echo c1"); assert_eq!(preview.participants, ["Pat"]); @@ -286,6 +288,24 @@ async fn decide_from_window_updates_state() { assert!(commands::decide(&state, "nope", true).await.is_err()); } +#[tokio::test] +async fn module_write_with_many_participants_is_approved_in_the_window() { + let d = daemon(&[ECHO]).await; + d.accounts.add("test-echo", info("a")).unwrap(); + let state = state(&d); + // A meeting of 60: too many for a toast or the dialog, all of them in the window. + let people: Vec = (1..=60).map(|n| json!([format!("p{n}"), format!("Person {n}")])).collect(); + let echo = d.accounts.modules().handle("test-echo").unwrap(); + echo.call("a", "set-target", &json!({"conversation": "c1", "participants": people}).to_string()).await.unwrap(); + let poked = d.registry.call("test_echo_poke", json!({"account": "test-echo:a", "conversation": "c1", "text": "hi"})).await.unwrap(); + let action = poked["action"].as_str().unwrap(); + let preview = &commands::waiting(&state)[0]; + assert_eq!((preview.kind.as_str(), preview.participants.len(), preview.target_cut), ("test_echo_poke", 60, false)); + assert_eq!(preview.description.as_deref(), Some("Pokes the conversation. It records its arguments as a write.")); + assert_eq!(preview.arguments, ["text: hi"]); + assert_eq!(commands::decide(&state, action, true).await.unwrap(), "done"); +} + #[tokio::test] async fn long_target_can_only_be_rejected() { let d = daemon(&[ECHO]).await; diff --git a/crates/open-mess/tests/update_apply.rs b/crates/open-mess/tests/update_apply.rs index 12ec3f1..ae00a72 100644 --- a/crates/open-mess/tests/update_apply.rs +++ b/crates/open-mess/tests/update_apply.rs @@ -324,6 +324,26 @@ fn install_swaps_exe_and_approves_version_only_module() { assert_eq!(std::fs::read(s.kept_backup().join("data").join("modules").join("echo.wasm")).unwrap(), common::echo()); } +#[test] +fn a_module_built_for_an_older_contract_is_still_the_predecessor() { + let new_echo = common::echo_with_version("9.9.9"); + let s = Setup::new(&echo_installed(), &[("echo.wasm", new_echo.clone())]); + // The approved module as a daemon with an older module contract approved it: a file this + // release's host cannot load, its approval and remembered manifest still in the database. + let manifest = s.modules()[0].manifest.clone().unwrap(); + let old = b"a module built for an older contract".to_vec(); + std::fs::write(s.module("echo.wasm"), &old).unwrap(); + let store = s.store(); + modules::approve(store.conn(), "test-echo", &sha(&old), &sha(modules::canonical(&manifest).to_string().as_bytes())).unwrap(); + modules::remember_approved(store.conn(), &manifest).unwrap(); + assert_eq!(s.run(&Machine::new(false, Health::Healthy)), Outcome::Installed); + // Replaced and approved as for a version-only change, not left waiting next to it. + assert_eq!(std::fs::read(s.module("echo.wasm")).unwrap(), new_echo); + let modules = s.modules(); + assert_eq!(modules.len(), 1, "{modules:?}"); + assert_eq!((modules[0].state.clone(), modules[0].version.as_str()), (ModuleState::Active, "9.9.9")); +} + #[test] fn earlier_backup_is_moved_aside() { let s = Setup::new(&echo_installed(), &[("echo.wasm", common::echo_with_version("9.9.9"))]); diff --git a/crates/open-mess/tests/write_tools.rs b/crates/open-mess/tests/write_tools.rs index 154219f..de2df24 100644 --- a/crates/open-mess/tests/write_tools.rs +++ b/crates/open-mess/tests/write_tools.rs @@ -1,15 +1,21 @@ -//! The write tools `send`, `edit_message` and `react`, and `pending`, through the tool registry. +//! The write tools `send`, `edit_message` and `react`, a module's own write tool (test-echo's +//! `poke`, spec 2026-10-29-live-captions 5.1), and `pending`, through the tool registry. // Test helpers outside #[test] functions aren't covered by clippy.toml's test allowances. #![allow(clippy::unwrap_used, clippy::indexing_slicing)] mod common; -use common::{ECHO, daemon, info}; -use open_mess::pending::MAX_WAITING; +use std::sync::Arc; +use std::time::Duration; + +use common::{Daemon, ECHO, daemon, info}; +use open_mess::modules; +use open_mess::pending::{Kind, MAX_WAITING, State}; use open_mess::permissions::{self, Write}; -use open_mess::tools::ToolError; +use open_mess::tools::{GateMode, ToolError}; use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; fn send(account: &str, text: &str) -> Value { json!({"account": account, "conversation": "c1", "text": text}) @@ -111,3 +117,175 @@ async fn pending_lists_states_without_text() { let newest = d.registry.call("pending", json!({"limit": 1})).await.unwrap(); assert_eq!(newest["items"][0]["account"], "test-echo:b"); } + +/// test-echo's tool directly, bypassing the core. +async fn echo(d: &Daemon, tool: &str, args: Value) -> Value { + let handle = d.accounts.modules().handle("test-echo").unwrap(); + serde_json::from_str(&handle.call("a", tool, &args.to_string()).await.unwrap()).unwrap() +} + +/// What test-echo was asked to write, in order. +async fn writes(d: &Daemon) -> Vec { + echo(d, "writes", json!({})).await["items"].as_array().unwrap().clone() +} + +fn poke(text: &str) -> Value { + json!({"account": "test-echo:a", "conversation": "c1", "text": text}) +} + +fn action(result: &Value) -> String { + result["action"].as_str().unwrap().to_owned() +} + +/// What `pending` says of the newest action. +async fn newest(d: &Daemon) -> Value { + d.registry.call("pending", json!({"limit": 1})).await.unwrap()["items"][0].clone() +} + +fn is_changing(result: &Result) -> bool { + matches!(result, Err(ToolError::Failed(why)) if why.contains("being updated or reloaded")) +} + +#[tokio::test] +async fn module_write_tool_always_waits_for_the_approval() { + let d = daemon(&[ECHO]).await; + d.accounts.add("test-echo", info("a")).unwrap(); + let (store, pending) = (common::store(d.dir.path()), d.registry.pending().unwrap()); + // Listed, the account it writes as required; one that names no conversation is not. + let listed = d.registry.list().into_iter().find(|tool| tool.name == "test_echo_poke").unwrap(); + assert_eq!(listed.input_schema["required"], json!(["conversation", "account"])); + assert!(!d.registry.list().iter().any(|tool| tool.name == "test_echo_nudge")); + let result = d.registry.call("test_echo_poke", json!({"conversation": "c1"})).await; + assert!(matches!(&result, Err(ToolError::InvalidArgs(why)) if why == "account is required"), "{result:?}"); + + // A write needs the write permission, not the read one, and waits for the user. + permissions::set(&store, "test-echo:a", None, false, Write::Confirm).unwrap(); + let first = d.registry.call("test_echo_poke", poke("hi")).await.unwrap(); + assert_eq!(first["state"], "waiting"); + let preview = pending.waiting()[0].clone(); + assert_eq!(preview.kind, Kind::ModuleTool("test_echo_poke".into())); + assert_eq!(preview.description.as_deref(), Some("Pokes the conversation. It records its arguments as a write.")); + assert_eq!( + (preview.conversation_title.as_str(), preview.participants.as_slice(), preview.arguments.as_slice()), + ("Echo c1", ["Pat".to_string()].as_slice(), ["text: hi".to_string()].as_slice()) + ); + assert_eq!((preview.text, preview.target_text), (None, None)); + // Writing on its own waits too; never is refused. + permissions::set(&store, "test-echo:a", None, true, Write::Autonomous).unwrap(); + assert_eq!(d.registry.call("test_echo_poke", poke("again")).await.unwrap()["state"], "waiting"); + permissions::set(&store, "test-echo:a", None, true, Write::Never).unwrap(); + let refused = d.registry.call("test_echo_poke", poke("no")).await; + assert!(matches!(&refused, Err(ToolError::NotAllowed(why)) if why == "writing to this conversation is not allowed"), "{refused:?}"); + assert!(writes(&d).await.is_empty()); + + // Approved, the module gets its own tool's name and the arguments without the account. + permissions::set(&store, "test-echo:a", None, false, Write::Confirm).unwrap(); + assert_eq!(pending.decide(&action(&first), true).await.unwrap(), State::Done); + assert_eq!(writes(&d).await, [json!({"tool": "poke", "args": {"conversation": "c1", "text": "hi"}})]); + // `pending` names it as the agent called it. + let done = d.registry.call("pending", json!({"state": "done"})).await.unwrap(); + assert_eq!((&done["items"][0]["kind"], &done["items"][0]["action"]), (&json!("test_echo_poke"), &first["action"])); +} + +#[tokio::test] +async fn module_write_approval_binds_to_the_title_not_the_participants() { + let d = daemon(&[ECHO]).await; + d.accounts.add("test-echo", info("a")).unwrap(); + let pending = d.registry.pending().unwrap(); + // People joining the meeting while the approval waits do not void it. + let joined = d.registry.call("test_echo_poke", poke("one")).await.unwrap(); + echo(&d, "set-target", json!({"conversation": "c1", "participants": [["p1", "Pat"], ["p2", "Sam"]]})).await; + assert_eq!(pending.decide(&action(&joined), true).await.unwrap(), State::Done); + // Another title does: the approval showed another conversation. + let renamed = d.registry.call("test_echo_poke", poke("two")).await.unwrap(); + echo(&d, "set-target", json!({"conversation": "c1", "title": "Another meeting"})).await; + assert_eq!(pending.decide(&action(&renamed), true).await.unwrap(), State::Voided); + assert_eq!(newest(&d).await["detail"], "the conversation's title changed after the approval; nothing was done"); + assert_eq!(writes(&d).await.len(), 1); +} + +#[tokio::test] +async fn module_write_tool_is_a_write_for_the_gate_and_the_module_block() { + let d = daemon(&[ECHO]).await; + d.accounts.add("test-echo", info("a")).unwrap(); + let pending = d.registry.pending().unwrap(); + // While an update's new version is checked, as `send` (known by its declaration). + d.registry.gate().set(GateMode::WritesBlocked); + let result = d.registry.call("test_echo_poke", poke("hi")).await; + assert!(matches!(&result, Err(ToolError::Failed(why)) if why == "update in progress, try again shortly"), "{result:?}"); + assert!(d.registry.call("test_echo_peek", json!({"account": "test-echo:a"})).await.is_ok()); + d.registry.gate().set(GateMode::Open); + + // While the modules change, no action is made and none approved calls its module. + let waiting = d.registry.call("test_echo_poke", poke("hi")).await.unwrap(); + { + let _block = d.accounts.module_writes().block(); + assert!(is_changing(&d.registry.call("test_echo_poke", poke("hi")).await)); + assert_eq!(pending.decide(&action(&waiting), true).await.unwrap(), State::Failed); + assert!(newest(&d).await["detail"].as_str().unwrap().contains("being updated or reloaded")); + } + assert!(writes(&d).await.is_empty()); + assert_eq!(d.registry.call("test_echo_poke", poke("hi")).await.unwrap()["state"], "waiting"); +} + +// Two threads, as in the daemon: test-echo's slow description holds one. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_block_that_starts_while_the_target_is_described_refuses_the_action() { + let d = daemon(&[ECHO]).await; + d.accounts.add("test-echo", info("a")).unwrap(); + // test-echo describes conversation "slow" for 1 s; the modules start to change meanwhile. + let call = d.registry.call("test_echo_poke", json!({"account": "test-echo:a", "conversation": "slow"})); + let meanwhile = async { + tokio::time::sleep(Duration::from_millis(300)).await; + d.accounts.module_writes().block() + }; + let (result, _block) = tokio::join!(call, meanwhile); + assert!(is_changing(&result), "{result:?}"); + assert!(d.registry.pending().unwrap().waiting().is_empty()); +} + +#[tokio::test] +async fn an_approval_runs_only_the_tool_it_showed() { + let d = daemon(&[ECHO]).await; + d.accounts.add("test-echo", info("a")).unwrap(); + let waiting = d.registry.call("test_echo_poke", poke("hi")).await.unwrap(); + // As if the module had been replaced since with one whose tool is declared otherwise: the + // approval's record of the tool no longer matches it. + let db = rusqlite::Connection::open(d.dir.path().join("state.sqlite3")).unwrap(); + db.execute("UPDATE pending SET fingerprint = substr(fingerprint, 1, 65) || 'another tool' WHERE id = ?1", [action(&waiting)]).unwrap(); + assert_eq!(d.registry.pending().unwrap().decide(&action(&waiting), true).await.unwrap(), State::Failed); + assert_eq!(newest(&d).await["detail"], "the module's tool changed after the approval; nothing was done"); + assert!(writes(&d).await.is_empty()); +} + +#[tokio::test] +async fn a_scan_blocks_module_writes_until_the_old_version_left() { + let d = daemon(&[ECHO]).await; + d.accounts.add("test-echo", info("a")).unwrap(); + // test-echo's `end-sessions` of this account takes 2 s. + d.accounts.add("test-echo", info("slow")).unwrap(); + // A new file of test-echo, approved: the scan swaps it in and the old version ends its sessions. + let rebuilt = common::echo_rebuilt(); + common::install(d.dir.path(), "echo.wasm", &rebuilt); + let manifest = d.accounts.modules().list()[0].manifest_sha256.clone(); + let file: String = Sha256::digest(&rebuilt).iter().map(|byte| format!("{byte:02x}")).collect(); + modules::approve(common::store(d.dir.path()).conn(), "test-echo", &file, &manifest).unwrap(); + let scan = |accounts: Arc| tokio::spawn(async move { accounts.rescan_modules(None).await.unwrap() }); + let first = scan(Arc::clone(&d.accounts)); + for _ in 0..1000 { + if d.accounts.module_writes().blocked() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(is_changing(&d.registry.call("test_echo_poke", poke("hi")).await)); + // A second scan meanwhile waits for the first and does not lift its block. + let second = scan(Arc::clone(&d.accounts)); + tokio::time::sleep(Duration::from_millis(300)).await; + assert!(is_changing(&d.registry.call("test_echo_poke", poke("hi")).await)); + assert_eq!(first.await.unwrap(), ["test-echo"]); + second.await.unwrap(); + // The old version ended both accounts' sessions; the new one takes writes. + assert!(!d.accounts.module_writes().blocked()); + assert_eq!(d.registry.call("test_echo_poke", poke("hi")).await.unwrap()["state"], "waiting"); +} diff --git a/crates/open-mess/ui/app.js b/crates/open-mess/ui/app.js index 925bb38..c3cf940 100644 --- a/crates/open-mess/ui/app.js +++ b/crates/open-mess/ui/app.js @@ -252,6 +252,10 @@ async function renderModules() { [tr("Platforma", "Platform"), module.platform || "-"], [tr("Hosty", "Hosts"), list(module.hosts)], [tr("Hosty logowania", "Sign-in hosts"), list(module.sign_in_hosts)], + [ + tr("Połączenie mediów (bez dźwięku i obrazu, poza proxy)", "Media connection (no sound or video, not through a proxy)"), + module.media.length > 0 ? list(module.media) : tr("brak", "none"), + ], [tr("Konto Windows (WAM)", "Windows account (WAM)"), wam], [tr("Własne narzędzia", "Own tools"), list(tools)], [tr("SHA-256 pliku", "File SHA-256"), el("code", module.file_sha256 || "-")], @@ -563,7 +567,7 @@ async function renderPermissions() { root.append( el( "p", - tr("Te same uprawnienia dotyczą każdego agenta i polecenia om. Ustawienie rozmowy ma pierwszeństwo przed ustawieniem konta. Z potwierdzeniem: każde wysłanie, edycja i reakcja czeka na Twoją decyzję w powiadomieniu albo w sekcji Do zatwierdzenia.", "The same permissions apply to every agent and to the om command. A conversation's setting comes before the account's. With confirmation: every send, edit and reaction waits for your decision in a notification or in the Waiting for approval section."), + tr("Te same uprawnienia dotyczą każdego agenta i polecenia om. Ustawienie rozmowy ma pierwszeństwo przed ustawieniem konta. Z potwierdzeniem: każde wysłanie, edycja i reakcja czeka na Twoją decyzję w powiadomieniu albo w sekcji Do zatwierdzenia. Narzędzia piszące modułów (np. dołączenie do spotkania dla napisów) czekają na nią zawsze, także przy zapisie samodzielnym.", "The same permissions apply to every agent and to the om command. A conversation's setting comes before the account's. With confirmation: every send, edit and reaction waits for your decision in a notification or in the Waiting for approval section. A module's write tools (such as joining a meeting for its captions) always wait for it, even when writing on its own is allowed."), "muted", ), ); @@ -624,7 +628,7 @@ function outcomes() { done: tr("Wykonano.", "Done."), rejected: tr("Odrzucono.", "Rejected."), expired: tr("Wygasło - nic nie wysłano.", "Expired - nothing was sent."), - voided: tr("Uczestnicy, wiadomość albo prawo zapisu zmieniły się od pokazania - nic nie wysłano.", "The participants, the message or the write permission changed since it was shown - nothing was sent."), + voided: tr("Uczestnicy, tytuł rozmowy, wiadomość albo prawo zapisu zmieniły się od pokazania - nic nie wysłano.", "The participants, the conversation's title, the message or the write permission changed since it was shown - nothing was sent."), uncertain: tr("Mogło dotrzeć - sprawdź rozmowę, zanim spróbujesz ponownie.", "It may have arrived - check the conversation before you try again."), failed: tr("Nie udało się - nic nie wysłano.", "It failed - nothing was sent."), }; @@ -638,15 +642,21 @@ function waitingCard(preview) { const card = el("div", null, "card"); card.append(el("strong", kinds()[preview.kind] || preview.kind)); const participants = preview.participants.length > 0 ? preview.participants.join(", ") : tr("brak (kanał)", "none (channel)"); + // A module tool's approval is for the conversation: who is in it now is shown, not approved. + const tool = preview.description !== null; card.append( details([ [tr("Konto", "Account"), preview.account], [tr("Rozmowa", "Conversation"), preview.conversation], - [tr("Uczestnicy", "Participants"), participants], + [tool ? tr("Teraz w rozmowie", "In it now") : tr("Uczestnicy", "Participants"), participants], ]), ); const block = (label, text) => card.append(el("h2", label), el("div", text, "content-text")); - if (preview.kind === "send") { + if (tool) { + block(tr("Co zrobi narzędzie modułu", "What the module's tool does"), preview.description); + if (preview.arguments.length > 0) block(tr("Argumenty", "Arguments"), preview.arguments.join("\n")); + card.append(el("p", tr("Zgoda dotyczy rozmowy, nie tylko osób, które są w niej teraz.", "The approval is for the conversation, not only for the people in it now."), "muted")); + } else if (preview.kind === "send") { block(tr("Treść", "Text"), preview.text); if (preview.target_text !== null) block(tr("W odpowiedzi na", "In reply to"), preview.target_text); } else if (preview.kind === "edit") { diff --git a/docs/protocols/teams.md b/docs/protocols/teams.md index 80653b1..1f95113 100644 --- a/docs/protocols/teams.md +++ b/docs/protocols/teams.md @@ -620,7 +620,9 @@ to the native calling stack), `calling-pluginless-6f37e714be91fccf.js` (the brow - **CONFIRMED** the user's own official client notices: `checkForActiveCallOnDifferentEndpoints` -> `activeCallOnDifferentEndpointExists` drives "You're in {{title}} on another device. Want to join on this one?", "How would you like to join on this device?", "Transfer to this device" and "Add this - device". + device". This is static analysis only, and the wording is that of the join flow on a device that + is not in the call. Live ("Live results" 18): with the owner in the meeting in the official client, + the owner never saw this notice while the probe's leg was in the call. - **UNCONFIRMED** what other participants see ("Needs live capture" 18): the call model keeps a participant's endpoints as a list (`userEndpointCount` = `endpoints.endpointDetails.length`, GraphQL `...endpointDetailsFields`), which suggests one roster row with two endpoints; the roster rendering, join toast and sound were not checked. @@ -639,9 +641,9 @@ offer SDP with audio and video inactive or disabled and a data channel, run ICE, Teams media relays, and parse the JSON messages of data id 3. That is a WebRTC-class transport, far beyond the HTTP and WebSocket host imports modules have today (a new host capability, its own spec). -Main risks: the user's own official client shows "You're in this meeting on another device" while +Main risks: the user's own official client may show "You're in this meeting on another device" while open_mess is in the call (it disturbs the official client, against AGENTS.md, unless the owner accepts -it); others probably see one entry but that is unverified; starting captions when no one has them on +it; live, the owner did not see it while in the meeting, "Live results" 18); others probably see one entry but that is unverified; starting captions when no one has them on adds the caption bot for everyone; E2EE meetings refuse the second device; a no-media timeout may end the call; the conversation-service host and media relay details come from ECS at run time and were not seen. A live check of 10.3 and 10.5 would need the owner's consent and a test meeting. @@ -704,6 +706,10 @@ desktop's SlimCore speaks it natively), the calling core `114419-5658b4e2d56443f - **CONFIRMED** TURN hosts from configuration: `default.relay.teams.microsoft.com` (UDP 3478, TCP and TLS 443), and `tr.teams.microsoft.com` (`TRAPRelayFqdn`); one configuration variant names the address `52.113.193.5`. No wildcard host and no published address ranges of media servers are in the bundle. +- **CONFIRMED** (Microsoft's published list, read 2026-11-04 from "Office 365 URLs and IP address + ranges", last updated 2026-08-14): Teams media, ID 11 (`OptimizeRequired`), is `52.112.0.0/14`, + `52.122.0.0/15` and `2603:1063::/38` on UDP 3478-3481; every media relay candidate seen live was in + 52.112.0.0/16 ("Live results" 15-18). These ranges are the module's `media.peers`. - **CONFIRMED** credentials: `GET https://teams.microsoft.com/trap/tokens` with `api-version: 2` and `X-Skypetoken` (or a bearer under the newer token API); the answer is `{expires, tokens: [{realm, username, password}]}` (realm `rtcmedia`); refreshed early from `earlyRefreshMinutes`. ICE servers are @@ -740,10 +746,21 @@ half of "Needs live capture" 7 and narrows 5 and 17) those flags come from configuration at run time and were not seen. - **CONFIRMED** v4c query (`buildV4cUrlParams`): `tc=`, `timeout=`, `epid=`, `ccid`, `dom` (if set), `cor_id`, - `con_num=_`; no `auth`, no `v`. The auth header goes on the upgrade request: - `X-Skypetoken` for a skype token, `Authorization: Bearer` for an AAD or CAE token. The server's - `trouter.connected` event then carries what allocate returns in v4a: `surl`, `connectparams`, `ttl`, - `ccid`, `id`, `reconnectUrl`, `curlb`. + `con_num=_`; no `auth`, no `v`. The server's `trouter.connected` event then + carries what allocate returns in v4a: `surl`, `connectparams`, `ttl`, `ccid`, `id`, + `reconnectUrl`, `curlb`. +- **CONFIRMED** (corrected 2026-10-30, REA `ev_548d5ad1...` on `cmd-trouter`, and live, "Live + results" 13) v4c authenticates inside the socket, not on the upgrade request (a browser WebSocket + cannot set headers): the socket opens anonymously (state `AnonymousConnecting`), and on `1::` the + client moves to `WebsocketAuthenticating` and emits the event + `5:::{"name":"user.authenticate","args":[{"headers":{...},"connectparams":{...}}]}` + (`sendV4cAuthenticationEvent`). `headers` is `extraConnectionHeaders` - one header, + `X-Ms-Test-User`, `"False"` for a normal user - plus the token headers of `z(token, + usingLegacyTokenApi)`: `X-Skypetoken` for a skype token or `Authorization: Bearer` for an AAD or + CAE token, and `X-MS-Migration: True` unless on the legacy token API. `connectparams` is the cached + `connectparams` of an earlier connection without `reconnectUrl` and `serviceUrl`, and is left out + on a first connection. Without this event the server sends `0::` with reason `timeout` 5 s after + `1::`. - **CONFIRMED** v4a details: allocate is a `POST` with an empty body and `Content-Type: text/plain`, query `cor_id`, `con_num`, `epid`, `tc` (no `auth`, no `timeout`), and the response must be JSON with `socketio` (base URL), `surl`, `ccid`, `id`, `ttl`, `connectparams` and optional `curlb`. The @@ -787,7 +804,8 @@ half of "Needs live capture" 7 and narrows 5 and 17) - **CONFIRMED** the offer as sent: no trickle (sent after gathering), then `toOffer()`, which filters candidates, sets port 9 on bundle-only sections and, unless `disableMsSdp` is set, applies the MS-SDP transform `toMsSdp`: `msid-semantic: WMS *`, profile `RTP/SAVP`, `a=label` per section - (`main-audio`, ...), the data section cut down to type, port, protocol, payload `34`, mid and label, + (`main-audio`, ...), a disabled (port 0) section cut down to type, port, protocol, payload `34`, + mid and label, `a=x-ssrc-range` (non-multistream), `|2^31` on crypto lines, the session fingerprint copied into each section, candidate and extension rewrites and `rtcp` set to the m-line port. That `disableMsSdp` exists suggests the service also takes plain SDP; **UNCONFIRMED**. @@ -798,6 +816,30 @@ half of "Needs live capture" 7 and narrows 5 and 17) balanceUpdate, retargetCompletion, ...}}}}` to `links.acknowledgement` (its own callback links). The client answers a callback 200 with an empty body; 400 for a missing body, a bad path or a failed decode, 404 for an unknown session. +- **CONFIRMED** (added 2026-10-30) joining a call that is already running is not a `POST` to the + service URL above: `joinGivenConversation(conversationUrl, ...)` calls `startOrJoinCall` with + `newCall: false` and `conversationServiceUrl` set to the call's own conversation URL, so the join + goes to that URL, with the body of `uw(...)` (the keys listed above; `groupChat.messageId` is + the session's `teamsMessageId`, `null` only when there is none - the web client sent `"0"` for a + "Meet now" meeting, "Live results" 14; no `participants.to`; `endpointMetadata` is + the session's, an empty object `{}` by default; `endpointState` only when muted or preheating). + The captured web join ("Live results" 14) shows the full sequence and supersedes this static + reading where they differ. Only a new call + (`newCall: true`, body `EV(...)`, which adds `participants.to` and `addModalitySuccess` / + `addModalityFailure` / `confirmUnmute` links) goes to the service URL. The conversation URL of a + live call is in the chat's thread property `awareness_conversationLiveState:` (a JSON string, + key `conversationUrl`) and equals the call store item's `conversationController` ("Live results" + 13). Request headers (`lw`): `X-Microsoft-Skype-Chain-ID`, `X-Microsoft-Skype-Message-ID`, + `X-Microsoft-Skype-Client` (`Gf(clientInformation)`; the Teams client passes a string, which `Gf` + keeps as it is: `SkypeSpaces//os=; osVer=; deviceType=`, plus + `; browser=..; browserVer=..` outside the desktop - `signalingAgentConfig.clientInformation` in + `114419`), + the token, `X-MS-Migration: True` with the generic token API, and `MS-Teams-Ring` / + `MS-Teams-Region` / `MS-Teams-Partition` when the local participant has them. +- **CONFIRMED** (corrected 2026-10-30) in the MS-SDP transform (`BO.toMsSdp`) the section cut down to + type, port, protocol, payload `34`, mid and label is a **disabled** one (port 0), not the data + section; every section gets the profile `RTP/SAVP` and an `a=label` (`main-audio`, `main-video`, + else the section's own label). - **CONFIRMED** join response (any 2xx; 200 or 201 not settled): `conversationController` (the conversation URL for later requests), `sequenceNumber`, `links` (`leave`, `addModality`, `removeModality`, `mute`, `updateEndpointMetadata`, `getAllParticipants` and others), @@ -871,7 +913,8 @@ the user present (spec 9.5): 3. Device code with `1fec8e78-...` (only with explicit consent, spec 9.5). 4. ~~The full authz response shape and real `regionGtms` keys~~ - done (see "Live results", item 2). 5. Which token Trouter allocate and AMS accept from open_mess (the chat service accepts a plain AAD - token for `https://ic3.teams.office.com` - see "Live results", item 3). + token for `https://ic3.teams.office.com` - see "Live results", item 3). Trouter v4c accepts the + same IC3 token in `user.authenticate` ("Live results" 13); AMS and v4a allocate are still open. 6. Exact values for `x-ms-client-type`, `x-ms-client-version` and `clientinfo` that the services accept from a non-official client. 7. Trouter allocate response fields, the downstream request envelope and the `/messaging` payload @@ -1172,6 +1215,440 @@ token, tenant id, user id or account name. still stands. `@oneDrive.displayNamesOfFileViewers` shows that a counted view would carry the viewer's name, which is why D3 matters. +13. **Step 0b of the live captions spec, second day** (2026-10-30, the owner at the computer and + agreeing in chat to each run; scratch probe outside the repo, own random endpoint id, no audio + sent or received, printing only status codes, key names, counts and redacted error bodies): + - Trouter v4c with `user.authenticate` (section 10.7): HTTP 101, `1::`, the event sent at once + with an `Authorization: Bearer` IC3 token (`https://ic3.teams.office.com`), `X-MS-Migration` + and `X-Ms-Test-User: False`; `trouter.connected` came 49-61 ms later, in five runs. Its keys: + `ccid`, `connectparams` (`issuer`, `scae`, `se`, `sig`, `sp`, `sr`, `st`), `curlb`, `dur`, + `healthUrl`, `id`, `reconnectUrl`, `registrarUrl`, `socketio`, `surl` (https), `ttl` (about + a week, in seconds), `url`. A connection with no meeting lived the full 61 s it was given, + with pings acked and 5 `trouter.message_loss` events and no downstream request. So an IC3 AAD + token is enough for Trouter ("Needs live capture" 5, Trouter part) and the 5 s `timeout` of + the first day was the missing `user.authenticate`. + - What the probe sent on Trouter, the same in every later run (items 14-18; written down from + its code on 2026-11-03): the query `tc` (URL-encoded JSON `{"cv": "2026.31.01.1", "ua": + "TeamsCDL", "hr": "", "v": "1415/"}`, the Trouter client version and the build + of the client), `timeout=40`, `epid` (its own random endpoint id), `cor_id` (a random UUID) + and `con_num` (`_`), no `ccid`; `user.authenticate` as + `5:::{"name": "user.authenticate", "args": [{"headers": {...}}]}`, its `connectparams` only + on a reconnection; `2::` echoed, `6:::` for each event `5:+::...`, and the ping + `5:+::{"name":"ping"}` every 15 s; every downstream request answered + `3:::{"id": , "status": 200, "headers": {}, "body": ""}` - no `MS-CV` or + `trouter-client` header, and the service went on (callbacks of items 15-18 came). The + registrar request had `X-Ms-Test-User: False`, `Accept: application/json, text/javascript`, + the IC3 bearer with `X-MS-Migration: True` and, in the body of section 5, `languageId` + `en-US`, `platform` `edge` and `platformUIVersion` `1415/`. + - What the probe sent to the conversation service in the data-only joins of item 18 (written + down from its code on 2026-11-04): every request with `X-Skypetoken`, `Content-Type: + application/json`, one `X-Microsoft-Skype-Chain-ID` for the call, a new + `X-Microsoft-Skype-Message-ID` each, `X-Microsoft-Skype-Client: SkypeSpaces/1415//os=windows; osVer=NT 10.0; deviceType=computer/TsCallingVersion=2026.37.01.11`, + `MS-Teams-Ring: general`, and `MS-Teams-Region` / `MS-Teams-Partition` with authz's `region` + and `partition`; no bearer. The call store `GET` had only the skype token. The join body: + `conversationRequest` (`conversationType` and `subject` null, `suppressDialout: true`, + `roster: {type: "Delta", rosterUpdate}`, the four `properties` of item 15, `links` + `conversationEnd`, `conversationUpdate`, `localParticipantUpdate`, `addParticipantSuccess`, + `addParticipantFailure`, `addModalitySuccess`, `addModalityFailure`, `confirmUnmute`, + `receiveMessage`), `groupContext: null`, `groupChat` (`threadId`, `messageId` from the call + store item's `groupContext`), `contentSharing: null`, `participants` (`from` = `id` (MRI), + `displayName`, `endpointId` (the Trouter `epid`), `participantId` (a random UUID), `languageId: + "en-US"`; `to: []`), `meetingData: null`, `meetingPreferences.shouldResurrect: "Resurrect"`, + `endpointState` (`endpointStateSequenceNumber: 0`, `state.isMuted: true`), `capabilities` + null, `endpointCapabilities: 72935`, `clientEndpointCapabilities` null, `endpointMetadata: {}`, + `meetingInfo` copied from the call store item, and `callInvitation` (`callModalities: []`, + `replaces`, `transferor`, `clientTransferContext`, `customContext`, `emergencyContent`, + `locationContent`, `networkContent`, `areaContent` null, `pstnContent` (`emergencyCallCountry: + ""`, `platformName` = the client header, `publicApiCall: false`), `links` `progress`, + `mediaAnswer`, `acceptance`, `redirection`, `end`, `clientContentForMediaController` + (`controlVideoStreaming`, `csrcInfo`), `mediaContent` (`contentType: + "application/sdp-ngc-1.0"`, `blob`, `clientLocation` - the owner's country, `mediaLegId` (32 hex + characters))). Every callback link was `callAgent//<8 random hex + characters>/`. The offer, from webrtc-rs's data-only offer: `b=CT:4000` after `s=-`, + `a=msid-semantic: WMS *`, the section `m=x-data RTP/SAVP 127 126` with `c=IN IP4`, port + and the single candidate of the first IPv4 UDP host candidate of component 1 (its extensions + dropped), `a=ice-options:trickle`, the three lines of item 16, `a=x-data-protocol:sctp`, + `a=x-ssrc-range:-`, `a=rtcp-mux`, the offer's `a=setup`, `a=mid`, `a=sendrecv`, + `a=sctp-port`, `a=ice-ufrag`, `a=ice-pwd`, `a=max-message-size:262144`, then `a=label:data` + and the session's `a=fingerprint`; `a=extmap-allow-mixed` and `a=end-of-candidates` left out. + `leave` had the body of section 10.7 with `phrase: "LOCAL_USER_INITIATED"` and code and subCode + 0. + - Call store: the items now have the keys `conversationController`, `correlationId`, + `groupContext` (`messageId`, `threadId`), `meetingInfo` (`organizerId`, `tenantId`) and + `userJoinTime`, and `groupContext.threadId` is the chat thread of the call - unlike item 11, + where no field held the thread (that was a scheduled meeting; these were a "Meet now" meeting + and a group chat call). The owner was in 2, then 3 calls at once. + - The thread property `awareness_conversationLiveState:` was missing on the owner's own + "Meet now" chat although he was still in that call, so it is not a reliable "call is live" + flag. On a group chat with a running call it was a JSON string with `callStartTime`, + `conversationId`, `conversationType`, `conversationUrl` (https), `expiration`, + `groupCallInitiator`, `isHostless`, `meetingInfo` (`isBroadcast`, `organizerId`, + `tenantId`) and `status`; its `conversationUrl` equalled the call store's + `conversationController`, of the form + `https://api.flightproxy.teams.microsoft.com/api/v2/ep//conv/?i=<13 digits>&e=<18 digits>`. + - `GET` of that conversation URL with `X-Skypetoken`: HTTP 200, about 41 KB, top keys + `activeModalities`, `compositionServiceDetails`, `conversationController`, + `conversationStartTime`, `creatorClientVersion`, `layoutDetails`, `links`, `meetingDetails`, + `meetingInfo`, `meetingStates`, `originalGroupCallInitiator`, `region`, `roster`, + `sequenceNumber`, `state`, `streamingSetupFailureDebugInfo`, `subject`, `tenantId`; `links` + include `addModality`, `addParticipantAndModality`, `conversationHttpTransport`, + `getAllParticipants`, `leave`, `removeModality`, `updateEndpointMetadata`; the response's own + `conversationController` equals the URL read, and `addModality`, `addParticipantAndModality` + (`.../add`) and `leave` are that URL with a path segment inserted before the query, while + `conversationHttpTransport` is a plain `http://` URL ending in `/enc`. + - Join attempts (each refused, so no call leg was created; the owner saw nothing in his client + and the other participants reported nothing). In the owner's "Meet now" meeting, alone: `POST` + to the service URL `.../api/v2/epconv/` gave HTTP 400 with the body `{}`, with a data-only + offer and `callModalities: []` and with an inactive audio section and `["Audio"]` - the wrong + URL for a running call (section 10.7). In a group chat call of the owner's friends (with the + owner's explicit OK in chat for that call; the probe only counts and never reads content): + `POST` to the conversation URL gave HTTP 503 in 240-320 ms with `operationFailure` + `{reason: "unknown", code: 503, subCode: 70051, phrase: "Proxy Operation Failure.", + resultCategories: ["UnexpectedServerError"]}`, for a plain data-only offer, a data-only offer + with the MS-SDP changes of 10.7 and an MS-SDP offer with an inactive audio section, and no + Trouter callback arrived while the request was open. Later the same day, in the same call and + with the owner's OK, each of these changed nothing (still 503, subCode 70051, in 200-340 ms): a + registrar registration of the probe's endpoint first (`POST ` from + `trouter.connected`, `registrar/prod/V2/registrations`, body of section 5 with + `TeamsCDLWebWorker` / `TeamsCDLWebWorker_2.6`, the surl as the only transport and TTL 900 s, + `Authorization: Bearer` IC3 token: HTTP 202 with an empty body; `DELETE + /` afterwards: HTTP 202), `endpointMetadata: {}`, the + `X-Microsoft-Skype-Client` value in the client's own format, the `MS-Teams-Region` and + `MS-Teams-Partition` headers, a roster-only join (no `callInvitation`, so no SDP at all), and + even the body `{}`, while a `GET` of the same URL with the same token works. The 503 is an + `UnexpectedServerError`, which an empty body also produces, so it does not by itself show + whether the body matters. Relay candidates were also missing from these offers (TURN + allocation on `default.relay.teams.microsoft.com` gave none), which matters only once a join + is accepted. Item 14 has the captured web join and the probe runs that followed it. + - Still open for step 0b: whether a data-only offer is accepted, data id 3 messages, and + whether the call lives without audio. + +14. **A captured join of the Teams web client** (2026-10-30, the owner's choice and OK in chat). Method: + a separate Edge instance with an empty temporary profile and a loopback-only DevTools port (not + the official desktop client, whose data and process were not touched); the owner signed in there + himself before any recording began; then a small DevTools listener of ours recorded only the + call-signaling requests while he joined his own "Meet now" meeting from that window, and printed + methods, URL shapes, header names, the values of a few non-secret headers, JSON key trees, + enum-like values and the SDP structure (addresses, ports, keys, fingerprints and ids cut; a slip + that printed roster map keys was scrubbed from the scratch log at once). REA's own + `capture_browser_scenario` refused to attach to the existing tab (`target_not_allowed`), so REA + was used for the static half only. The web client runs at `https://teams.cloud.microsoft`. + Every request below carried `content-type: application/json`, `ms-teams-region`, + `ms-teams-partition`, `ms-teams-ring: general`, one `x-microsoft-skype-chain-id` for the whole + call, a fresh `x-microsoft-skype-message-id`, `x-ms-migration: True`, `x-skypetoken` and + `x-microsoft-skype-client: + SkypeSpaces/1415//os=windows; osVer=NT 10.0; deviceType=computer; browser=edgeChromium; browserVer=/TsCallingVersion=/Ovb=`, + over HTTP/2. The sequence: + 1. Registrar registrations from the page and from a worker (`POST + https://teams.microsoft.com/registrar/prod/V2/registrations`). + 2. `POST https://api-.flightproxy.teams.microsoft.com/api/v2/epconv` - a regional host, + no trailing slash - with **no `callInvitation`**: `conversationRequest` (`subject: null`, + `roster`, `properties` with `enableGroupCallEventMessages: true`, `links` whose callbacks + point at `http://broker.invalid/callAgent/...` because Trouter was not ready yet), + `groupContext: null`, `groupChat: null`, `participants.from` (`languageId` `pl-pl`), + `capabilities: null`, `endpointCapabilities: 73463`, `clientEndpointCapabilities: 114259690`, + `endpointMetadata.holographicCapabilities`, `meetingInfo: null`, `meetingData` (`meetingCode` + 16 digits, `passcode` - the `p` of the join link, `meetingUrl`), `meetingPreferences` + (`shouldResurrect: "Resurrect"`), `endpointState` (`endpointStateSequenceNumber: 0`, + `endpointProperties.additionalEndpointProperties`), `debugContent`. Answer: HTTP 200 with + `conversationController`, `links`, `meetingDetails`, `roster`, `state` + (`conversationType: "scheduledMeeting"` for a "Meet now" meeting) and a `location` header. + This is how a meeting join link (`/meet/?p=`, which holds no thread) leads to + the running call. + 3. `POST /notificationLinks` with `participants.from`, `roster` and the full + set of Trouter callback links (`https:///v4/f//callAgent///conversation/...`): + HTTP 200. It moves the callbacks from the broker to Trouter. + 4. `POST ` (the `conversationController` from step 2, which equals the live + call's URL) - the join: the body of `EV(...)` (`participants.to: []`, `contentSharing: null`, + `conversationRequest.links` with `addModalitySuccess` / `addModalityFailure` / + `confirmUnmute`), `groupChat` (`threadId`, `messageId: "0"`), `meetingInfo` (`organizerId`, + `tenantId`), `meetingData` (`meetingCode`, an 8-character `passcode` the service returned), + `meetingPreferences`, `endpointState` (also `preheatProperties`), `participantPropertyBag`, and + `callInvitation` with `callModalities: ["Audio", "ScreenViewer"]`, `replaces`, `transferor`, + `clientTransferContext`, `customContext` and `emergencyContent` all `null`, the five call + links, `clientContentForMediaController` (`controlVideoStreaming`, `csrcInfo` links), + `pstnContent` (`emergencyCallCountry`, `platformName`, `publicApiCall`), `locationContent`, + `networkContent`, `areaContent` `null`, and `mediaContent` with `contentType: + "application/sdp-ngc-1.0"`, `blob`, `clientLocation` (2 letters), `mediaDescriptions` + (`descriptions[]` of `mid` and `direction`, `requestId`), `applyChannelParameters` and + `mediaLegId` (32 characters). HTTP 200. + 5. `POST https://api.flightproxy.teams.microsoft.com/api/v2/ep//cc/v1/active/...` with + `callParticipantUpdate.links` (the client's call callbacks) and + `clientContentForMediaController`: HTTP 202. Then `updateEndpointState` (POST) and + `updateEndpointMetadata` (PUT) on the conversation. + - **The offer** (`blob`): 13 sections in one BUNDLE, all `RTP/SAVP` with `a=x-signaling-fb`, + `a=x-ssrc-range`, `a=setup:actpass`, `a=ice-options:trickle`, `a=rtcp-mux` and `a=label`: + `main-audio` (sendrecv), ten `main-video` (sendrecv and recvonly), `applicationsharing-video`, + and **the data channel as `m=x-data RTP/SAVP 127 126` with `a=x-data-protocol:sctp`, + `a=label:data` and `a=sctp-port:5000`** - not `m=application ... webrtc-datachannel` (that is + what `toMsSdp` makes of it; section 10.7 did not show it). The offer held a single host + candidate. + - A separate broker channel is long-polled throughout: `GET + .../api/v2/ep//api/v1/subscribe//0?i=`, HTTP 202 with `message`, + `nextSubscribeUrl` and `expirationTimeInSec`. + - The probe after the capture (same meeting, the owner in it with captions on, his OK for each + run): step 2 copied from the web client returned HTTP 200 and the live call's URL - but only + with the web client's `endpointCapabilities` (without the broker and reinviteless bits: + 72935); with 1088 the service answered **201 and created a new conversation** for the meeting, + which ended at once (`/conversation/conversationEnd/` arrived during the next request), and an + empty meeting gives 201 too. Step 4 with the web client's body keys, one chain id per call, + an `m=x-data` offer (data only, and with an inactive audio section), and a roster-only join + after step 2, all still gave 503 subCode 70051 in about 300 ms. What is left between the web + client and the probe is the transport and the browser headers (HTTP/2, `User-Agent`, + `Origin`), the remaining body values (`clientEndpointCapabilities`, `languageId`, + `endpointMetadata`, `pstnContent`, `mediaDescriptions`, `participantPropertyBag`) and the rest + of the SDP (13 sections, trickle). Not tested: a probe that copies a browser's `User-Agent` and + `Origin` was stopped before it ran - open_mess must not pass itself off as a browser. + - Later runs the same day (same meeting, owner's OK): over HTTP/2 (negotiated by ALPN) with an + honest `User-Agent` of the probe, step 2 again gave 200 and **step 3 `notificationLinks` gave + HTTP 200**, and a `/conversation/rosterUpdate/` callback (keys `participantCounts`, + `participants`, `sequenceNumber`, `type`) arrived over Trouter - so POSTs to the conversation + URL do work and the service knows the probe's endpoint. The join of step 4 still gave 503 + 70051 with each of: `subject` taken from step 2's answer, `pstnContent` as the client builds + it (`emergencyCallCountry: ""`, `platformName` = the client header, `publicApiCall: false`), + the web client's `endpointCapabilities` 73463 and `clientEndpointCapabilities` 114259690 with + `mediaDescriptions` (`mid`, `direction` per section, `requestId`), an offer whose m= and c= + lines carry the default host candidate's address and port with that one candidate, + `a=ice-options:trickle`, `b=CT:4000`, `a=x-ssrc-range`, `a=rtcp`, `a=x-signaling-fb` and + `a=max-message-size:262144`, and with the audio section `inactive` or (as a diagnostic, no + track, nothing sent or received) `sendonly`. A second capture, with the owner choosing not to + use audio in the web pre-join screen, showed the same offer: the web client always offers + audio `sendrecv`, ten video sections and `m=x-data`, and mutes afterwards. After the probe's + registrar registration, chat notifications arrived on the Trouter path `/messaging` (keys only + were looked at); the registration was deleted after each run (HTTP 202). + - **Solved later the same day, see item 15** (the probe wrapped the join body in `payload`). + What the service missed in step 4 was first thought to be one of: the remaining body values + (`languageId`, `endpointMetadata.holographicCapabilities`, `endpointState.endpointProperties`, + `participantPropertyBag`, `applyChannelParameters`, the `confirmUnmute` link), the offer's + other sections (audio `sendrecv` plus video, which open_mess may not use) and the browser + request headers. + +15. **The probe joins the call** (2026-10-30, the owner alone in his own "Meet now" meeting with + live captions on, his OK in chat for each run; a file-only review by Codex pointed at the first + item below): + - **The wire body has no `payload` level.** The client's request builder `lw` sends + `JSON.stringify(data.payload)`, and the builders (`uw`, `EV`, `_V` for leave, the acceptance + acknowledgement) return `{payload: {...}}`, so what goes on the wire is the inner object - as + the capture of item 14 shows (`conversationRequest`, `participants`, `callInvitation` at the + root). Section 10.7 lists those keys under `payload.`; read them as root keys. The probe had + wrapped its join, leave and acknowledgement in `payload`, which is what the 503 70051 of items + 13 and 14 answered (an empty `{}` too). Unwrapped: the join returned **HTTP 200** with the + conversation (about 40 KB), `leave` HTTP 204 and the acceptance acknowledgement HTTP 202. + - Offer with only `m=x-data` and `callModalities: []`: join 200, then `/call/end/` at once. + - Offer with an audio section `inactive` plus `m=x-data`, `callModalities: ["Audio"]`: join 200, + `/call/acceptance/` came with the answer: one BUNDLE (`0`), `m=audio 3478 RTP/SAVP 111 9 0 8` + `inactive`, `a=setup:passive`, `a=x-source-streamid`, four relay candidates on port 3478 in + 52.112.0.0/16 (two UDP with an `MTURNID ` extension, two `tcp-pass`), and **`m=x-data 0 + RTP/SAVP 127 126` - the data section rejected**; `acceptedCallModalities: []`; the acceptance + `mediaContent` keys `blob`, `contentType`, `escalationOccurring`, `fromMixer`, `mediaLegId`, + `newOffer`, `skipIceReinvite`. ICE never connected. + - The same with the audio transceiver **`sendonly` and no track** (nothing is sent; the probe + refuses any answer whose audio section would send audio to it, failing closed when no + direction is given) and `endpointState.state.isMuted: true`: the answer's audio was `recvonly`, + ICE **connected** to the relay candidates, and the data section was again `m=x-data 0`. Then + the service ended the leg about 2-3 s later: `/call/end/` with `callEnd` code 410, subCode + 301067, phrase `DTLSHandshakeFailed`, reason `clientError`, result categories `JoinFailure`, + `Other`. webrtc-rs logged `Failed to start manager dtls: ring::error::Unspecified`. + - **The media processor's DTLS certificate is ECDSA P-384 and signs with SHA-256.** The `dtls` + crate 0.13 that webrtc-rs 0.14 uses verifies ECDSA with SHA-256 only on P-256, so the + handshake failed in `verify_signature`; `allow_insecure_verification_algorithm` does not cover + it. With a local patch to the probe's copy of the crate that picks the curve from the + certificate's key (`ECDSA_P384_SHA256_ASN1`), the peer connection reached **connected** and the + leg stayed up for the 90 s of the run, the service sending only RTCP; the probe then left + (HTTP 204). Step 1 of the live captions spec needs a WebRTC stack that verifies P-384 with + SHA-256 (the fix upstreamed or carried). + - **The web client's own answer accepts the data section.** A further capture (same method as + item 14) showed that the web join's HTTP 200 response itself carries `callAcceptance` (with + `acceptedCallModalities: []`, `links` for `acknowledgement`, `callLeg`, `mediaRenegotiation`, + `startOutgoingNegotiation`, `applyChannelParameters` and more, and `mediaContent` with + `skipIceReinvite: true`), and its answer SDP has `m=x-data RTP/SAVP 127 126` with + `a=x-source-streamid`, `a=setup:passive`, `a=x-ssrc-range`, `a=x-data-protocol:sctp`, + `a=label:data`, `a=rtcp-mux` - accepted - next to audio and eleven video sections. The client + acknowledged it with `POST .../cc/v1/callAcceptance/.../acknowledge`. + - For the probe the data section stayed at port 0 with each of: a video section `sendonly` with + no track added (answered `recvonly`, the leg stayed up), the web client's + `clientEndpointCapabilities` 114259690, the reinviteless bit 512 without `mediaDescriptions`, + and `/TsCallingVersion=` in the client string. Bit 512 with the probe's own + `mediaDescriptions` (one per section) ended the leg at once: `callEnd` 410, subCode 301999, + `InvalidFinalAnswerMediaDescriptions`; the client builds that list only from `sendrecv` + sections and adds the data section only with `subTypes` (`getMediaDescriptions`). In the + client, data `subTypes` are set later (`setSubTypes`) and only for real-time text in PSTN + calls, not for captions. + - **The data channel is started by a renegotiation in the client** (static, `calling-pluginless` + and `SlimCoreWebview2.dll`): `startDataChannel` enables modality 3 and calls + `updateMediaModalities`; the native code has `DataChannelStart` and "Data channel operation + offer" (the desktop joins with `datachannelDirection` Disabled by default). The renegotiation + is `POST ` with `{"mediaNegotiation": + {"callModalities", "sender" {id, displayName, endpointId, participantId, languageId}, "links" + {"mediaAnswer", "rejection" (path /call/rejection/)}, "mediaContent"}}` (`qH`); the answer + comes on `/call/mediaAnswer/` and is acknowledged with an empty `POST` to + `mediaAnswer.links.mediaAcknowledgement`. Closed captions register a handler for data id 3 on + the calling bot's data channel (`registerDataCallback(call, 3, ClosedCaptionsMW, ..., + SourceAndSink)`); the handler decodes `recognitionResults` / `textTracks` JSON. + - Live, in a meeting of the owner's with ten other people (his explicit OK in chat for that + meeting; the probe only counts): join with audio `sendonly` only (no data section) - 200, + acceptance, ICE and DTLS connected; three seconds later the renegotiation adding `m=x-data` + - HTTP 202, `/call/mediaAnswer/` with audio `recvonly` and **`m=x-data 0` again**, + acknowledgement HTTP 202; the leg stayed up for 120 s, then left (204). That meeting's bot had + `closedCaptions` and `realTimeTranscript` `Active`. Asked afterwards, the other participants + had seen nothing of the probe's endpoint (the owner's report) - a first answer to "Needs live + capture" 18 and decision O2 of the live captions spec. The owner then ran, himself, the same + probe with the web client's build number in `X-Microsoft-Skype-Client` (a run the session's + permission classifier had stopped): the same result, `m=x-data 0` in the renegotiation answer - + the client version is not what the service decides on. + - Two diagnostics the owner decided on also left the data section at port 0: audio `sendrecv` + with no track (the service then sends audio; webrtc-rs dropped it undecoded, as there was no + track handler) and the renegotiation above. The cause was elsewhere - item 16. + +16. **Live captions received** (2026-10-30, the owner's meeting with nine or ten other people, + captions on, his OK in chat for a series of runs; the probe counts, prints key names only and + never reads, prints or stores caption text, names or ids): + - **The cause of the rejected data section**: the client's MS-SDP converter (`Kz.modify` in + `calling-pluginless`) gives the `m=x-data ... 127 126` section the lines + `a=rtpmap:127 x-data/90000`, `a=rtpmap:126 rtx/90000` and `a=fmtp:126 apt=127`; the probe + did not, and the service answered the section with port 0. (The capture tool of item 14 left + `rtpmap` and `fmtp` lines out of its print, which hid them.) For the answer the client's + `Xz` turns the section back into `m=application UDP/DTLS/SCTP webrtc-datachannel`, + drops its `rtpmap`, `fmtp`, `rtcp` and x- lines and adds `a=sctp-port` (the answer has none). + - **The working offer**: one BUNDLE of `m=audio` `sendonly` with no track (nothing is sent; the + service answers `recvonly`, so it sends no audio) and `m=x-data RTP/SAVP 127 126` + with the three lines above, `a=x-data-protocol:sctp`, `a=x-ssrc-range:-`, `a=rtcp-mux`, + `a=setup:actpass`, `a=sendrecv`, `a=sctp-port:5000`, `a=max-message-size:262144`, + `a=label:data`, one host candidate and `a=ice-options:trickle`; `callModalities: ["Audio"]`, + one `mediaLegId` for the call. The data section goes in the first offer; no renegotiation is + needed. + - **The answer** (in `/call/acceptance/`): audio `recvonly` on port 3478 and **`m=x-data 3478 + RTP/SAVP 127 126`** with `a=label:data` and `a=x-data-protocol:sctp`, four relay candidates + in 52.112.0.0/16; ICE and DTLS (P-384, item 15) connected and the SCTP data channel + `main-channel` opened once. + - **Captions arrived with nothing sent on the channel**: in 120 s 301 messages, all on data id + 3, framed as section 10.7 says (0 broken frames); 299 recognition results, 24 of them + final. A second run of 30 s: 55 messages, 54 results, 11 final. Message top keys + `events`, `recognitionResults`, `version`; result keys `confidence`, `displayName`, + `duration`, `id`, `isFinal`, `model`, `source`, `speechRecognitionServiceLatency`, + `spokenLanguage`, `temporaryStorageOnly`, `text`, `timestampAudioSent`, `trackLanguage`, + `userId`. No `textTracks` in these runs. + - So step 0b of the live captions spec is answered, but for one offer only: audio `sendonly` + with no track plus the data section is accepted. The data-only offer and the offer with + audio `inactive` (item 15) were tried only before the three lines above were added, so + whether either of them works now is still open (a retest, step 0c of the spec). The leg + lives without audio flowing (two minutes, ended by the probe's own `leave`, HTTP 204), the + caption messages are on data id 3, and the other participants saw nothing (item 15 - asked + after runs whose data channel did not open; not asked again after these runs). The + join that received captions went through the `epconv` request with `meetingData` from a + meeting link; the join at the call store's `conversationController` (item 15, HTTP 200) + has not yet carried the data section. The steps the module + needs are those of items 14-16: registrar registration, Trouter v4c with + `user.authenticate`, the regional `epconv` request with `meetingData` (or the live call's + URL), `notificationLinks`, the join without a `payload` envelope, the acceptance + acknowledgement, MS-SDP both ways, and a DTLS stack that verifies P-384 with SHA-256. + +17. **What the roster shows for the probe's leg** (2026-10-30, the owner's own "Meet now" meeting with + an anonymous guest he joined himself as an observer and, in the second run, a colleague he + invited for the test; his OK in chat for both runs; the probe printed only key names, counts and + enum values of the owner's own roster entry): + - Run A, the owner in the meeting: the probe's join (HTTP 200) added a second endpoint to the + owner's entry (`endpoints` 1 to 2, `state: active`, `isMuted: true`, `role: admin`) - one + roster entry with two endpoints (what the observer saw at that moment was not asked). The + owner then hung up in the official client: his entry stayed + `active` with one endpoint (the probe's), and the observer still saw him in the meeting. The + entry went only when the probe left (`leave`, HTTP 204). Captions kept coming (36 messages, 7 + final, all on data id 3). + - Run B, the owner not in the meeting (the probe's own presence check turned off for this run): + the `epconv` request with `meetingData` from the link and the join both gave HTTP 200, with no + lobby (the owner organizes the meeting); the owner's entry appeared `active` with one endpoint, + and the observer saw the owner join. The data channel opened and captions came (110 + messages, 18 final; a result key `rawText` appeared next to those of item 16). + - So a leg is never hidden: whenever any endpoint of the account is in the call, the account is + in the roster as present. A leg without the user is not an invisible listener but a stand-in + that shows the user as present - which is why the live captions spec joins only while the user + is in the meeting and leaves when the user's last other endpoint goes. Anyone holding the + user's tokens could do the same; that is an account compromise, not a gap in the service. + - `timestampAudioSent` is a number that is not a Unix time (s, ms, us), .NET ticks or a + FILETIME within 10 minutes of the local clock, so probably relative to something; + `duration` was of the order 1e6-1e7 (in 100 ns units that would be 0.1-10 s; unconfirmed). + +18. **Step 0c of the live captions spec** (2026-11-02, a scheduled team meeting the owner organizes, from + the owner's calendar, the colleagues in it told about the tests, captions on in the owner's + official client, the owner's OK in chat for the runs; the probe printed only codes, key names, counts and enum values). All joins + went the module's way: the conversation URL from the call store item whose + `groupContext.threadId` is the meeting chat, no `epconv`, no `notificationLinks`, and the probe + left Trouter unread while the join `POST` was open, answering its callbacks only afterwards (as + the module will, its frames arriving between calls): + - **Scheduled meeting**: the call store had one item, keys `conversationController`, + `correlationId`, `groupContext` (`messageId`, `threadId`), `meetingInfo` (`organizerId`, + `tenantId`), `userJoinTime`; `groupContext.threadId` was the meeting chat's thread, and + `conversationController` was the same URL as the `conversationUrl` of the chat's + `awareness_conversationLiveState` (an https URL on `api.flightproxy.teams.microsoft.com`). A + `GET` of that URL returns the conversation with the `roster` and `links` (`leave`, + `notificationLinks` among them). Item 11 found no `groupContext` on a scheduled meeting on + 2026-10-29; what differed then is not known. + - **Join at the call store's URL, callbacks answered late**: HTTP 200 in 0.35-1.4 s; no callback + was answered while the `POST` was open, and the acceptance with the SDP answer came after it. + Offer audio `sendonly` with no track plus the data section: the data channel opened, 183 + messages in 90 s, 19 final results, 0 broken frames. The service does not wait for the + callbacks' answers. + - **Data-only offer** (one `m=x-data` section, `callModalities: []`): accepted, answer + `m=x-data 3480 RTP/SAVP` with four relay candidates in 52.112.0.0/16, ICE and DTLS connected, + 126 messages in 60 s, 15 final. So no audio section is needed. + - **Audio `inactive` plus the data section**: answered with audio `inactive` and the data section, + but ICE failed (one try, not repeated, since the data-only offer works); the roster delta then + showed the owner's entry with one endpoint. + - **Callbacks**: one `/call/acceptance/` and one `/conversation/rosterUpdate/` per run, and 5 + `trouter.message_loss` events in every run (cause unknown; test B of item 17 had 1). + - **Trouter reconnection** (the same day, no meeting): after a clean close, a new socket 3 s later + with the same `epid` and the cached `connectparams` (without `reconnectUrl` and `serviceUrl`) in + `user.authenticate` got `trouter.connected` with the same `surl`, `id`, `ccid` and + `reconnectUrl`, both at `reconnectUrl` (a `wss://` URL on `*.teams.microsoft.com:443`, path + `v4/c`, no query) and at the default `go.trouter` URL. + - **The user's endpoints in the roster**: the entry's `endpoints` is an object keyed by endpoint + id. The probe's endpoint showed `languageId` `en-US`, `isMuted: true`, one media stream, + `endpointCapabilities` 67 and no `endpointMetadata`; the official client's showed the client's + language, four media streams and `endpointMetadata.transcriptionPrefs.closedCaptions: true`. + - **Others**: the owner asked the colleagues after the runs; nobody saw anything. The owner, + in the meeting in the official client, never saw the "on another device" notice of + section 10.5 - that notice belongs to a device that is not in the call. + - **A leg that vanishes without `leave`** (the probe exited after captions came, with no `leave`, + no DTLS or SCTP close and no unregister): the leg stayed. Reads of the conversation every 15 s + showed the owner's entry with two endpoints, `state: active`, for 10 min 20 s; the probe then + sent the skipped `leave` from a new process as that endpoint (its `endpointId` and + `participantId` from the roster entry, picked as the one endpoint with the probe's own + `clientVersion`, `en-US`, muted and no transcription preferences) - HTTP 204, and the next read + showed one endpoint. So a leg with no media flowing does not end within 10 minutes when its + client vanishes - whether it ends when the registration's 900 s TTL runs out, or only with + the meeting, is not known - and a `leave` sent as that endpoint from another process ends it. + +19. **Step 5 of the live captions spec: open_mess itself in a meeting** (2026-11-04, a scheduled + team meeting from the owner's calendar, the owner in it in the official client with captions on, + their OK in chat for the run; a second open_mess daemon built from the branch, with its own data + folder and port, its module logs on stderr (`OPEN_MESS_LOG=info`); only states, codes, counts and + timings were printed - no caption text, names or ids). Four joins, each approved by the owner in + the configuration window (the toast said the approval was too long to show there, as spec 5.1 + plans for a meeting's participant list): + - **Callbacks come compressed.** The join's `endpointCapabilities` 72935 (from item 14) has bit + 128, and the callbacks after the join arrived with `X-Microsoft-Skype-Content-Encoding: gzip` + and a base64 body; the probe had inflated them without noting it. The module answered them 400, + the acceptance never reached it, and about 2 s after the join (HTTP 200) the service ended the + leg: `/call/end/` with `callEnd` code 430, sub-code 10067. With the bodies inflated (gzip), + `/call/acceptance/` came. + - **The data-only answer has RTP stream lines.** Its data section carried `a=ssrc-group` (and + stream lines with it), which the strict converter refused; with `a=ssrc`, `a=ssrc-group` and + `a=msid` lines dropped the host took the answer. + - The working run: registration HTTP 202, join HTTP 200 in 0.7 s, the answer accepted 1.7 s + after the join, the data channel open 2.4 s later; the first final caption read 3 s after that. + In about 1.5 minutes 19 final cues from 4 speakers, each read 2-3 s after it arrived, `missed` + 0, `captions_on` true throughout, the spoken language set. A message the owner wrote in the + meeting chat came back from `read_messages` with `since` (with the untrusted notice). The + roster was read 7 times (callbacks and message losses), each finding the owner's other + endpoint and captions on. `teams_leave_captions` ended the session (`left`). + - The probe's values went unchanged and were accepted: Trouter `ua: TeamsCDL`, the registrar's + `platform: edge`, `clientLocation: PL`; whether Teams takes open_mess's own values was not + tried. + - **Visibility**: the owner's official client showed nothing about another device, during the + session or after it, and did not show them twice in the roster; the other people, asked by the + owner, noticed nothing worth mentioning. + All checks in items 1-4 and 7-12 were read-only: no endpoint registration, no property write, no Trouter connection. Apart from the slip noted in item 9, values printed during the checks were limited to status codes, key names, counts, enum values, scope names and service URLs. diff --git a/modules/teams/Cargo.toml b/modules/teams/Cargo.toml index fbbf8c0..a5e3c19 100644 --- a/modules/teams/Cargo.toml +++ b/modules/teams/Cargo.toml @@ -12,6 +12,8 @@ publish.workspace = true crate-type = ["cdylib", "rlib"] [dependencies] +# Trouter's compressed callbacks (endpoint capability bit 128, trouter.rs); pure Rust (miniz_oxide). +flate2 = "1.1.10" serde_json = "1.0.154" url = "2.5.8" diff --git a/modules/teams/src/api.rs b/modules/teams/src/api.rs index a2086b5..858a451 100644 --- a/modules/teams/src/api.rs +++ b/modules/teams/src/api.rs @@ -37,7 +37,12 @@ thread_local! { /// Starts a call's budget; every export calls it first. pub fn begin() { - DEADLINE.set(Some(Instant::now() + CALL_BUDGET)); + begin_within(CALL_BUDGET); +} + +/// Starts a shorter budget, for an export the host limits more (`end-sessions`, 5 s). +pub fn begin_within(budget: Duration) { + DEADLINE.set(Some(Instant::now() + budget)); BACKGROUND.set(false); } @@ -53,7 +58,7 @@ pub fn has_time() -> bool { } /// Waits `ms` if that ends within the budget. -fn pause(ms: u32) -> bool { +pub fn wait(ms: u32) -> bool { let wait = Duration::from_millis(u64::from(ms)); if DEADLINE.get().is_some_and(|deadline| Instant::now() + wait > deadline) { return false; @@ -110,9 +115,11 @@ pub fn uuid() -> String { format!("{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", a >> 32, (a >> 16) & 0xffff, a & 0xffff, b >> 48, b & 0xffff_ffff_ffff) } -/// A token for `scope` from WAM, and its claims. Only sign-in may ask the user. -fn wam(scope: &str, interactive: bool, claims: Option<&str>) -> Result<(String, Claims), ModuleError> { - let token = sign_in::wam_token(&[scope.to_string()], interactive, claims).map_err(|e| { +/// A token for `scope` from WAM, and its claims. Only sign-in may ask the user. `account` names +/// the module account, so an event (a timer, a socket frame) gets that account's token when there +/// are several (spec 2026-10-29-live-captions, 5.2); a sign-in names none. +fn wam(scope: &str, interactive: bool, claims: Option<&str>, account: Option<&str>) -> Result<(String, Claims), ModuleError> { + let token = sign_in::wam_token(&[scope.to_string()], interactive, claims, account).map_err(|e| { ModuleError::AuthRequired(match e { sign_in::SignInError::Cancelled => "sign-in was cancelled".to_string(), sign_in::SignInError::TimedOut => "sign-in timed out".to_string(), @@ -125,7 +132,7 @@ fn wam(scope: &str, interactive: bool, claims: Option<&str>) -> Result<(String, /// Signs in through WAM, interactively, and checks the account with Teams. pub fn sign_in() -> Result { - let (token, claims) = wam(AUTHSVC_SCOPE, true, None)?; + let (token, claims) = wam(AUTHSVC_SCOPE, true, None, None)?; // The user's time in the WAM dialog does not count against the host's limit (spec 8.3, R4), // so it does not count against the budget either. begin(); @@ -182,7 +189,7 @@ pub fn polling_again() { } fn open(account: &str) -> Result { - let (token, claims) = wam(AUTHSVC_SCOPE, false, None)?; + let (token, claims) = wam(AUTHSVC_SCOPE, false, None, Some(account))?; if claims.oid != account { return Err(ModuleError::AuthRequired("Windows now signs in another account".to_string())); } @@ -195,13 +202,14 @@ fn establish(token: String, claims: Claims) -> Result { let auth = authorize(&token)?; let expires = claims.expires_ms; let mut session = Session::new(claims, auth.directory); + session.region = auth.region; session.keep(AUTHSVC_SCOPE, token, expires); session.keep_skype(auth.skype_token, auth.expires_at_ms); Ok(session) } fn authorize(token: &str) -> Result { - let response = exchange(&authsvc::request(), token, false, false)?; + let response = exchange(&authsvc::request(), Some(token), false, false)?; match response.status { 200..=299 => {} 401 | 403 => return Err(ModuleError::AuthRequired(format!("Teams refused the account (HTTP {})", response.status))), @@ -214,6 +222,7 @@ fn authorize(token: &str) -> Result { fn reauthorize(session: &mut Session, token: &str) -> Result<(), ModuleError> { let auth = authorize(token)?; session.directory = auth.directory; + session.region = auth.region; session.keep_skype(auth.skype_token, auth.expires_at_ms); Ok(()) } @@ -226,22 +235,49 @@ pub fn middle_tier(session: &mut Session, req: &HttpRequest) -> Result, if session.skype(now_ms(), true).is_none() { reauthorize(session, &token)?; } - let first = exchange(&with_skype(session, req)?, &token, false, false)?; + let first = exchange(&with_skype(session, req)?, Some(&token), false, false)?; if first.status != 401 { return ok(first); } let claims = header(&first.headers, "www-authenticate").and_then(session::challenge_claims); let token = renew(session, AUTHSVC_SCOPE, claims.as_deref())?; reauthorize(session, &token)?; - let second = exchange(&with_skype(session, req)?, &token, false, false)?; + let second = exchange(&with_skype(session, req)?, Some(&token), false, false)?; if second.status == 401 { return Err(ModuleError::AuthRequired("the service refused a new token".to_string())); } ok(second) } +/// A request to the conversation service of live captions with the skype token and no bearer, as +/// the probe sent them ("Live results" 14-18). A skype token about to expire is renewed by a new +/// authsvc exchange first; a 401, which the service did not take, gets one more exchange and one +/// more try. +pub fn conversation(session: &mut Session, req: &HttpRequest, write: bool) -> Result { + if session.skype(now_ms(), true).is_none() { + let token = token(session, AUTHSVC_SCOPE)?; + reauthorize(session, &token)?; + } + let first = exchange(&with_skype(session, req)?, None, false, write)?; + if first.status != 401 { + return Ok(first); + } + let token = renew(session, AUTHSVC_SCOPE, None)?; + reauthorize(session, &token)?; + let second = exchange(&with_skype(session, req)?, None, false, write)?; + if second.status == 401 { + return Err(ModuleError::AuthRequired("the service refused a new skype token".to_string())); + } + Ok(second) +} + +/// The IC3 token of Trouter's `user.authenticate`, the chat service's (docs 10.7). +pub fn chat_token(session: &mut Session) -> Result { + token(session, CHAT_SCOPE) +} + fn with_skype(session: &Session, req: &HttpRequest) -> Result { - let skype = session.skype(now_ms(), false).ok_or_else(|| ModuleError::NotSupported("no calendar service for this account".to_string()))?; + let skype = session.skype(now_ms(), false).ok_or_else(|| ModuleError::NotSupported("Teams gave no skype token for this account".to_string()))?; let mut req = req.clone(); req.headers.push(("x-skypetoken".to_string(), skype.to_string())); Ok(req) @@ -279,7 +315,7 @@ fn token(session: &mut Session, scope: &str) -> Result { /// A new token from WAM, silently, for the session's own account only. fn renew(session: &mut Session, scope: &str, claims: Option<&str>) -> Result { - let (token, c) = wam(scope, false, claims)?; + let (token, c) = wam(scope, false, claims, Some(&session.account))?; if c.oid != session.account { return Err(ModuleError::AuthRequired("Windows now signs in another account".to_string())); } @@ -293,13 +329,13 @@ fn renew(session: &mut Session, scope: &str, claims: Option<&str>) -> Result Result { let scope = service.scope(); - let first = exchange(req, &token(session, scope)?, service == Service::Chat, write)?; + let first = exchange(req, Some(&token(session, scope)?), service == Service::Chat, write)?; if first.status != 401 { return Ok(first); } let claims = header(&first.headers, "www-authenticate").and_then(session::challenge_claims); let token = renew(session, scope, claims.as_deref())?; - let second = exchange(req, &token, service == Service::Chat, write)?; + let second = exchange(req, Some(&token), service == Service::Chat, write)?; if second.status == 401 { return Err(ModuleError::AuthRequired("the service refused a new token".to_string())); } @@ -312,11 +348,11 @@ pub fn send(session: &mut Session, service: Service, req: &HttpRequest, write: b /// 2026-10-28-meetings-transcripts, 4.2). pub fn sharepoint(session: &mut Session, origin: &str, req: &HttpRequest) -> Result, ModuleError> { let scope = format!("{origin}/.default"); - let mut response = exchange(req, &token(session, &scope)?, false, false)?; + let mut response = exchange(req, Some(&token(session, &scope)?), false, false)?; if response.status == 401 { let claims = header(&response.headers, "www-authenticate").and_then(session::challenge_claims); let token = renew(session, &scope, claims.as_deref())?; - response = exchange(req, &token, false, false)?; + response = exchange(req, Some(&token), false, false)?; } match response.status { 401 | 403 => Err(ModuleError::Platform("access to this transcript was refused".to_string())), @@ -345,9 +381,10 @@ pub fn header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str } /// One exchange under the chat service's retry policy (docs 8): 429 and 5xx again after the wait, -/// a write only after a 429, which the service did not take. `redirects`: follow the chat -/// service's regional redirects, a 404 with `Location` on the same host (docs 3.1). -fn exchange(req: &HttpRequest, token: &str, redirects: bool, write: bool) -> Result { +/// a write only after a 429, which the service did not take. `token` goes as the bearer, if any. +/// `redirects`: follow the chat service's regional redirects, a 404 with `Location` on the same +/// host (docs 3.1). +fn exchange(req: &HttpRequest, token: Option<&str>, redirects: bool, write: bool) -> Result { let mut url = req.url.clone(); let (mut attempt, mut hops) = (0u8, 0u8); loop { @@ -358,7 +395,9 @@ fn exchange(req: &HttpRequest, token: &str, redirects: bool, write: bool) -> Res return Err(ModuleError::Platform("Teams answers too slowly; try again later".to_string())); } let mut headers = req.headers.clone(); - headers.push(("Authorization".to_string(), format!("Bearer {token}"))); + if let Some(token) = token { + headers.push(("Authorization".to_string(), format!("Bearer {token}"))); + } if BACKGROUND.get() { headers.push(("x-ms-request-priority".to_string(), "20".to_string())); } @@ -383,7 +422,7 @@ fn exchange(req: &HttpRequest, token: &str, redirects: bool, write: bool) -> Res match retry::decide(status, header(&response.headers, "retry-after"), attempt, now_ms()) { Retry::Done => return Ok(response), Retry::After(ms) => { - if !pause(ms) { + if !wait(ms) { return Err(ModuleError::Platform(format!("the service asks to wait {} s; try again later", ms.div_ceil(1000)))); } attempt += 1; diff --git a/modules/teams/src/authsvc.rs b/modules/teams/src/authsvc.rs index 24afe4f..1a4afb0 100644 --- a/modules/teams/src/authsvc.rs +++ b/modules/teams/src/authsvc.rs @@ -16,6 +16,9 @@ pub struct Session { pub skype_token: Option, pub expires_at_ms: Option, pub directory: ServiceDirectory, + /// `region` and `partition`, which live-captions requests carry as `MS-Teams-Region` and + /// `MS-Teams-Partition` ("Live results" 14); left out unless short plain names. + pub region: Option<(String, String)>, } /// Base URLs of the services, from `regionGtms`. Only https URLs are accepted. @@ -85,7 +88,13 @@ pub fn decode(body: &[u8], now_ms: i64) -> Result { (Some(SecretString::new(token.to_string())), expires_in.map(|secs| now_ms.saturating_add(secs.saturating_mul(1000)))) } }; - Ok(Session { skype_token, expires_at_ms, directory }) + let name = |key: &str| { + v.get(key) + .and_then(Value::as_str) + .filter(|n| (1..=32).contains(&n.len()) && n.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')) + .map(str::to_string) + }; + Ok(Session { skype_token, expires_at_ms, directory, region: name("region").zip(name("partition")) }) } #[cfg(test)] diff --git a/modules/teams/src/captions.rs b/modules/teams/src/captions.rs new file mode 100644 index 0000000..7e78dd5 --- /dev/null +++ b/modules/teams/src/captions.rs @@ -0,0 +1,1201 @@ +//! Live captions of a Teams meeting (spec 2026-10-29-live-captions, 4 and 5.3; +//! docs/protocols/teams.md 10.7 and "Live results" 11-18): a session's state and deadlines, the +//! caption messages of the call's data channel, the buffer agents read, and the requests and +//! answers of the conversation service. No network and no clock: the glue (`live.rs`) does the +//! I/O and passes the time in. + +use std::collections::{HashMap, VecDeque}; + +use serde_json::{Value, json}; +use url::Url; + +use crate::http::HttpRequest; +use crate::meetings::{cut, language_tag}; +use crate::{ProtoError, incompatible, parse_json}; + +/// The host of the call store and of every conversation URL the module sends a token to (spec +/// 4.1, R16). +pub const SERVICE_HOST: &str = "api.flightproxy.teams.microsoft.com"; +/// The client the probe named in `X-Microsoft-Skype-Client` and `pstnContent` ("Live results" +/// 14 and 18). +const CLIENT: &str = "SkypeSpaces/1415/26260.1717.5229.7049/os=windows; osVer=NT 10.0; deviceType=computer/TsCallingVersion=2026.37.01.11"; +/// The web client's endpoint capabilities without the broker and reinviteless bits, which the +/// join at the call store's URL needs ("Live results" 14 and 18). Bit 128 asks for compressed +/// callbacks, which `trouter.rs` inflates ("Live results" 19). +const ENDPOINT_CAPABILITIES: u64 = 72935; +/// ponytail: the probe's `clientLocation`; the client sends the user's country. Take it from the +/// account if Teams turns out to care (step 5 of the spec). +const CLIENT_LOCATION: &str = "PL"; + +/// Deadlines of a session (spec 4.1-4.3). +pub const JOIN_MS: i64 = 60_000; +pub const CAPTIONS_WAIT_MS: i64 = 30_000; +pub const CAPTIONS_OFF_MS: i64 = 5 * 60_000; +pub const IDLE_MS: i64 = 10 * 60_000; +pub const TIME_LIMIT_MS: i64 = 3 * 3_600_000; +/// How long the cues of an ended session can still be read. +pub const KEEP_MS: i64 = 15 * 60_000; +/// The roster is read this often while Trouter is down (spec 5.3, Codex R24). +pub const ROSTER_POLL_MS: i64 = 10_000; +/// A failed `leave` is sent again this often, for this long (spec 4.3, Codex R21). +pub const LEAVE_RETRY_MS: i64 = 30_000; +pub const LEAVE_RETRIES_FOR_MS: i64 = 10 * 60_000; +/// A session record older than this gets one `leave` and goes, whatever the answer (spec 4.3). +pub const STALE_RECORD_MS: i64 = 4 * 3_600_000; + +/// The buffer of one session (spec 5.3). +const MAX_CUES: usize = 20_000; +const MAX_BUFFER_BYTES: usize = 4 * 1024 * 1024; +const MAX_SPEAKER: usize = 200; +const MAX_TEXT: usize = 4000; +const MAX_PAGE_CUES: usize = 2000; +/// A message reassembled from fragments is cut off past this; the host passes at most 64 KiB a +/// fragment. +const MAX_MESSAGE: usize = 1024 * 1024; +/// Unreadable messages in a row that end a session (spec 5.3). +const MAX_BAD_IN_A_ROW: u32 = 100; +/// The data id of caption messages on `main-channel` (docs 10.2). +const CAPTIONS_DATA_ID: u8 = 3; +/// Session records kept per account; a record past them is the oldest and goes. +const MAX_RECORDS: usize = 8; + +/// Why a session ended (spec 4.2). Lobby, end-to-end encryption and policy refusals have no +/// signal of their own yet; the service's refusal of the join is `Refused`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Reason { + MeetingEnded, + Update, + UserLeft, + UserNotInMeeting, + Idle, + TimeLimit, + Left, + Refused, + NoCaptions, + MediaFailed, + Proxy, + ConnectionLimit, + Protocol, +} + +impl Reason { + pub fn name(self) -> &'static str { + match self { + Reason::MeetingEnded => "meeting_ended", + Reason::Update => "update", + Reason::UserLeft => "user_left", + Reason::UserNotInMeeting => "user_not_in_meeting", + Reason::Idle => "idle", + Reason::TimeLimit => "time_limit", + Reason::Left => "left", + Reason::Refused => "refused", + Reason::NoCaptions => "no_captions", + Reason::MediaFailed => "media_failed", + Reason::Proxy => "proxy", + Reason::ConnectionLimit => "connection_limit", + Reason::Protocol => "protocol", + } + } + + /// Whether the session failed rather than ended in the normal way. + fn failed(self) -> bool { + !matches!(self, Reason::MeetingEnded | Reason::Update | Reason::UserLeft | Reason::Idle | Reason::TimeLimit | Reason::Left) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum State { + Joining, + Live, + Ended, + Failed, +} + +impl State { + pub fn name(self) -> &'static str { + match self { + State::Joining => "joining", + State::Live => "live", + State::Ended => "ended", + State::Failed => "failed", + } + } +} + +/// What is due in a session ([`Session::due`]). +#[derive(Debug, PartialEq, Eq)] +pub enum Due { + /// End the session (and leave the meeting). + End(Reason), + /// Read the roster again (Trouter is down). + Roster, + /// The ended session's cues are gone: forget it. + Forget, +} + +/// A refused read ([`Session::read`]). +#[derive(Debug, PartialEq, Eq)] +pub struct SessionChanged; + +/// One final caption. +struct Cue { + at_ms: i64, + speaker: Option, + text: String, + language: Option, + /// Its share of the buffer's byte limit. + bytes: usize, +} + +/// The cues of a session, numbered from 0; the oldest go past the limits. +#[derive(Default)] +struct Buffer { + cues: VecDeque, + /// The number of the oldest cue kept. + first: u64, + bytes: usize, + /// Cues up to this number have been on a page. + read_to: u64, + /// Cues lost: dropped by the host or the module, or gone from the buffer unread. + missed: u64, +} + +impl Buffer { + fn next(&self) -> u64 { + self.first + self.cues.len() as u64 + } + + fn push(&mut self, cue: Cue) { + while !self.cues.is_empty() && (self.cues.len() >= MAX_CUES || self.bytes + cue.bytes > MAX_BUFFER_BYTES) { + if let Some(gone) = self.cues.pop_front() { + self.bytes -= gone.bytes; + if self.first >= self.read_to { + self.missed += 1; + } + self.first += 1; + } + } + self.bytes += cue.bytes; + self.cues.push_back(cue); + } + + /// The cues from `cursor` (from the oldest kept when it is older) that fit in `max_bytes` of + /// JSON - at least one, at most [`MAX_PAGE_CUES`] - the next cursor, and the last one's language. + fn page(&mut self, cursor: u64, max_bytes: usize) -> (Vec, u64, Option) { + let start = cursor.clamp(self.first, self.next()); + let skip = usize::try_from(start - self.first).unwrap_or(usize::MAX); + let (mut items, mut used, mut language) = (Vec::new(), 0, None); + for cue in self.cues.iter().skip(skip) { + let item = json!({"at": crate::time::rfc3339(cue.at_ms), "speaker": cue.speaker, "text": cue.text}); + let size = item.to_string().len(); + if !items.is_empty() && (used + size > max_bytes || items.len() == MAX_PAGE_CUES) { + break; + } + used += size; + language = cue.language.clone(); + items.push(item); + } + let next = start + items.len() as u64; + self.read_to = self.read_to.max(next); + (items, next, language) + } +} + +/// The data channel's messages (docs 10.7): an 11-byte big-endian header and 4 bytes a recipient, +/// then the payload; a message in several fragments is put back together by sequence number. +#[derive(Default)] +struct Feed { + /// Data id -> sequence number, fragments still to come and the payload so far. + pending: HashMap)>, + bad_in_a_row: u32, +} + +struct Fragment<'a> { + start: bool, + data_id: u8, + seq: u16, + remaining: u8, + payload: &'a [u8], +} + +fn fragment(bytes: &[u8]) -> Option> { + let (first, second, flags) = (*bytes.first()?, *bytes.get(1)?, *bytes.get(2)?); + if first >> 4 != 1 { + return None; + } + let header = (usize::from(first & 15) << 8) + usize::from(second); + let recipients = usize::from(*bytes.get(10)?); + if header < 11 + 4 * recipients { + return None; + } + Some(Fragment { + start: flags & 0x80 != 0, + data_id: flags & 63, + seq: u16::from_be_bytes([*bytes.get(3)?, *bytes.get(4)?]), + remaining: *bytes.get(5)?, + payload: bytes.get(header..)?, + }) +} + +impl Feed { + /// A whole message once its last fragment came: its data id and payload. `Err` for a fragment + /// that cannot be read or does not follow the one before. + fn reassemble(&mut self, bytes: &[u8]) -> Result)>, ()> { + let f = fragment(bytes).ok_or(())?; + if f.start { + if f.remaining == 0 { + return Ok(Some((f.data_id, f.payload.to_vec()))); + } + self.pending.insert(f.data_id, (f.seq, f.remaining, f.payload.to_vec())); + return Ok(None); + } + let (seq, remaining, mut payload) = self.pending.remove(&f.data_id).ok_or(())?; + if f.seq != seq.wrapping_add(1) || payload.len() + f.payload.len() > MAX_MESSAGE { + return Err(()); + } + payload.extend_from_slice(f.payload); + if remaining <= 1 { + return Ok(Some((f.data_id, payload))); + } + self.pending.insert(f.data_id, (f.seq, remaining - 1, payload)); + Ok(None) + } + + /// The final captions of one data channel message, arrived at `now`. `None` when it cannot be + /// read; then [`Feed::broken`] says whether that ends the session. + fn push(&mut self, bytes: &[u8], now: i64) -> Option> { + let cues = match self.reassemble(bytes) { + Ok(Some((CAPTIONS_DATA_ID, payload))) => captions(&payload, now), + // Another data id, or a fragment of a message still coming. + Ok(_) => Some(Vec::new()), + Err(()) => None, + }; + self.bad_in_a_row = if cues.is_some() { 0 } else { self.bad_in_a_row + 1 }; + cues + } + + fn broken(&self) -> bool { + self.bad_in_a_row >= MAX_BAD_IN_A_ROW + } +} + +/// The final results of a caption message (keys of "Live results" 16): `recognitionResults`, each +/// with `isFinal`, `text`, `displayName` and `spokenLanguage`. `None` when it is not that shape. +fn captions(payload: &[u8], now: i64) -> Option> { + let message: Value = serde_json::from_slice(payload).ok()?; + let results = match message.as_object()?.get("recognitionResults") { + None => return Some(Vec::new()), + Some(results) => results.as_array()?, + }; + let mut cues = Vec::new(); + for result in results { + let result = result.as_object()?; + if result.get("isFinal").and_then(Value::as_bool) != Some(true) { + continue; + } + let text = cut(result.get("text")?.as_str()?, MAX_TEXT); + let speaker = result.get("displayName").and_then(Value::as_str).map(|name| cut(name, MAX_SPEAKER)); + let language = result.get("spokenLanguage").and_then(Value::as_str).filter(|tag| language_tag(tag)).map(str::to_string); + let bytes = text.len() + speaker.as_ref().map_or(0, String::len) + 64; + cues.push(Cue { at_ms: now, speaker, text, language, bytes }); + } + Some(cues) +} + +/// What a roster says for the session (spec 4.1, 4.2). +#[derive(Debug, PartialEq, Eq)] +pub struct Roster { + /// Another endpoint of the user is in the call: the user is in the meeting there. + pub user_elsewhere: bool, + /// The captions bot is in the call with `closedCaptions` active. + pub captions: bool, +} + +/// One live-captions session of an account (spec 4). +pub struct Session { + pub conversation: String, + /// The `session` agents pass back (base64url, random). + pub token: String, + state: State, + reason: Option, + started: i64, + ended: Option, + last_read: i64, + /// The service's SDP answer was accepted. + answered: bool, + /// When the data channel opened. + channel_at: Option, + /// A roster showed the user's other endpoint. + roster_seen: bool, + captions_on: Option, + /// Captions were on 30 s after the data channel opened (spec 4.1), and since when they are off. + checked: bool, + off_since: Option, + /// Since Trouter went down or lost a message: cues are dropped until a roster is read again. + paused: bool, + /// Trouter is down: the roster is read every 10 s until it is back (spec 5.3, Codex R24). + down: bool, + roster_at: Option, + buffer: Buffer, + feed: Feed, +} + +impl Session { + pub fn new(conversation: &str, token: &str, now: i64) -> Session { + Session { + conversation: conversation.to_string(), + token: token.to_string(), + state: State::Joining, + reason: None, + started: now, + ended: None, + last_read: now, + answered: false, + channel_at: None, + roster_seen: false, + captions_on: None, + checked: false, + off_since: None, + paused: false, + down: false, + roster_at: None, + buffer: Buffer::default(), + feed: Feed::default(), + } + } + + pub fn state(&self) -> State { + self.state + } + + pub fn active(&self) -> bool { + matches!(self.state, State::Joining | State::Live) + } + + /// Ends the session, once; the cues stay readable for [`KEEP_MS`]. + pub fn end(&mut self, reason: Reason, now: i64) { + if self.active() { + self.state = if reason.failed() { State::Failed } else { State::Ended }; + self.reason = Some(reason); + self.ended = Some(now); + } + } + + /// The service's SDP answer was accepted. + pub fn answered(&mut self) { + self.answered = true; + } + + /// The data channel opened: the session is live. + pub fn channel_open(&mut self, now: i64) { + if self.state == State::Joining { + self.state = State::Live; + self.channel_at = Some(now); + } + } + + /// Trouter lost a message: the roster may have changed unseen (spec 5.3). + pub fn pause(&mut self, now: i64) { + self.paused = true; + self.captions_on = None; + self.roster_at.get_or_insert(now.saturating_add(ROSTER_POLL_MS)); + } + + /// Trouter went down: no callback arrives until it is back. + pub fn down(&mut self, now: i64) { + self.down = true; + self.pause(now); + } + + /// Trouter is back; the next roster read lets captions in again. + pub fn up(&mut self) { + self.down = false; + } + + /// A roster read: the reason to end the session, if the user is not in the meeting. While + /// Trouter is down it only checks that; captions wait for Trouter (spec 5.3). + pub fn roster(&mut self, roster: &Roster, now: i64) -> Option { + if !roster.user_elsewhere { + return Some(if self.roster_seen { Reason::UserLeft } else { Reason::UserNotInMeeting }); + } + self.roster_seen = true; + // Whether captions are on counts even while Trouter is down (Codex review of 263a526). + if roster.captions { + self.off_since = None; + } else { + self.off_since.get_or_insert(now); + } + if self.down { + self.roster_at = Some(now.saturating_add(ROSTER_POLL_MS)); + return None; + } + self.paused = false; + self.roster_at = None; + self.captions_on = Some(roster.captions); + None + } + + /// Data channel messages and how many the host dropped: the reason to end the session when + /// too many in a row cannot be read. + pub fn data(&mut self, messages: &[Vec], dropped: u32, now: i64) -> Option { + self.buffer.missed += u64::from(dropped); + if self.paused || !self.active() { + self.buffer.missed += messages.len() as u64; + return None; + } + for message in messages { + match self.feed.push(message, now) { + Some(cues) => cues.into_iter().for_each(|cue| self.buffer.push(cue)), + None => self.buffer.missed += 1, + } + if self.feed.broken() { + return Some(Reason::Protocol); + } + } + None + } + + /// What is due at `now`, the most urgent first. Notes that captions were on 30 s after the data + /// channel opened, once they were. + pub fn due(&mut self, now: i64) -> Option { + if !self.active() { + return self.ended.is_some_and(|at| now >= at.saturating_add(KEEP_MS)).then_some(Due::Forget); + } + let past = |at: i64, wait: i64| now >= at.saturating_add(wait); + if past(self.started, TIME_LIMIT_MS) { + return Some(Due::End(Reason::TimeLimit)); + } + if past(self.last_read, IDLE_MS) { + return Some(Due::End(Reason::Idle)); + } + if self.state == State::Joining && past(self.started, JOIN_MS) { + return Some(Due::End(if self.answered { Reason::MediaFailed } else { Reason::Protocol })); + } + // Nobody had captions on 30 s after the channel opened; a gap decides at the next roster read. + if !self.checked && !self.paused && self.channel_at.is_some_and(|at| past(at, CAPTIONS_WAIT_MS)) { + if self.captions_on != Some(true) { + return Some(Due::End(Reason::NoCaptions)); + } + self.checked = true; + } + if self.checked && self.off_since.is_some_and(|at| past(at, CAPTIONS_OFF_MS)) { + return Some(Due::End(Reason::NoCaptions)); + } + if self.roster_at.is_some_and(|at| now >= at) { + return Some(Due::Roster); + } + None + } + + /// Whether an ended session's cues are gone. + pub fn gone(&self, now: i64) -> bool { + self.ended.is_some_and(|at| now >= at.saturating_add(KEEP_MS)) + } + + /// A roster read is done (or failed): the next one during a gap. + pub fn roster_polled(&mut self, now: i64) { + if self.paused { + self.roster_at = Some(now.saturating_add(ROSTER_POLL_MS)); + } + } + + /// When something is due next. + pub fn next_at(&self) -> Option { + if !self.active() { + return self.ended.map(|at| at.saturating_add(KEEP_MS)); + } + let mut times = vec![self.started.saturating_add(TIME_LIMIT_MS), self.last_read.saturating_add(IDLE_MS)]; + if self.state == State::Joining { + times.push(self.started.saturating_add(JOIN_MS)); + } + if !self.checked && !self.paused { + times.extend(self.channel_at.map(|at| at.saturating_add(CAPTIONS_WAIT_MS))); + } + if self.checked { + times.extend(self.off_since.map(|at| at.saturating_add(CAPTIONS_OFF_MS))); + } + times.extend(self.roster_at); + times.into_iter().min() + } + + /// The result of `teams_read_captions` (spec 4.2). `session` from the agent must be this one's; + /// a read counts against the idle limit. + pub fn read(&mut self, session: Option<&str>, cursor: Option, max_bytes: usize, now: i64) -> Result { + if session.is_some_and(|s| s != self.token) { + return Err(SessionChanged); + } + let (cues, next, language) = self.buffer.page(cursor.unwrap_or(0), max_bytes); + self.last_read = now; + let captions_on = if self.active() { self.captions_on } else { None }; + Ok(json!({ + "state": self.state.name(), + "session": self.token, + "captions_on": captions_on, + "next_cursor": next, + "missed": self.buffer.missed, + "language": language, + "reason": self.reason.map(Reason::name), + "untrusted": {"cues": cues}, + })) + } +} + +/// Whether `url` is one the module sends the skype token to: https on [`SERVICE_HOST`], port +/// 443, no user info (spec 4.1, R16). +pub fn on_service(url: &str) -> bool { + Url::parse(url).is_ok_and(|u| { + u.scheme() == "https" + && u.username().is_empty() + && u.password().is_none() + && u.port_or_known_default() == Some(443) + && u.host_str() == Some(SERVICE_HOST) + }) +} + +/// The user's live call in the meeting, from the call store (spec 4.1). +#[derive(Debug, PartialEq)] +pub struct Live { + /// Where to join and read the roster. + pub controller: String, + pub meeting_info: Value, + pub message: String, +} + +/// The call store's request (docs 10.7, "Live results" 11): the user's calls. +pub fn call_store(mri: &str) -> HttpRequest { + let url = format!("https://{SERVICE_HOST}/api/v2/ep/api.userstore.skype.com/api/v1/user/{mri}/conversations"); + HttpRequest { method: "GET".to_string(), url, headers: Vec::new(), body: Vec::new() } +} + +/// The call store item of the meeting chat `thread`, if the user is in that meeting ("Live +/// results" 18: an item's `groupContext.threadId`). A matching item whose URL the module would not +/// send a token to is an error. +pub fn live_call(body: &[u8], thread: &str) -> Result, ProtoError> { + let v = parse_json(body)?; + let items = v.as_array().ok_or_else(|| incompatible("$"))?; + let Some(item) = items.iter().find(|item| item.pointer("/groupContext/threadId").and_then(Value::as_str) == Some(thread)) else { + return Ok(None); + }; + let controller = item.get("conversationController").and_then(Value::as_str).filter(|url| on_service(url)); + let controller = controller.ok_or_else(|| incompatible("conversationController"))?.to_string(); + let meeting_info = item.get("meetingInfo").filter(|info| info.is_object()).cloned().unwrap_or(Value::Null); + let message = item.pointer("/groupContext/messageId").and_then(Value::as_str).unwrap_or("0").to_string(); + Ok(Some(Live { controller, meeting_info, message })) +} + +/// open_mess's endpoint in the call, as `participants.from` names it. +pub struct Me<'a> { + pub mri: &'a str, + pub name: &'a str, + pub endpoint: &'a str, + pub participant: &'a str, +} + +impl Me<'_> { + fn json(&self) -> Value { + json!({"id": self.mri, "displayName": self.name, "endpointId": self.endpoint, "participantId": self.participant, "languageId": "en-US"}) + } +} + +/// The callback URLs of a session: `callAgent//` (as the probe made +/// them, "Live results" 15), a tag a link. +pub struct Links<'a> { + pub surl: &'a str, + pub session: &'a str, + pub tag: u32, +} + +impl Links<'_> { + fn link(&self, n: u32, path: &str) -> String { + let slash = if self.surl.ends_with('/') { "" } else { "/" }; + format!("{}{slash}callAgent/{}/{:08x}{path}", self.surl, self.session, self.tag.wrapping_add(n)) + } +} + +/// The join (`POST `): the probe's body that gave captions with a data-only offer +/// ("Live results" 15 and 18) - no `payload` envelope, muted, no call modalities. +pub fn join(me: &Me<'_>, links: &Links<'_>, live: &Live, thread: &str, offer: &str, media_leg: &str) -> Value { + let l = |n: u32, path: &str| links.link(n, path); + json!({ + "conversationRequest": { + "conversationType": null, + "subject": null, + "suppressDialout": true, + "roster": {"type": "Delta", "rosterUpdate": l(0, "/conversation/rosterUpdate/")}, + "properties": { + "allowConversationWithoutHost": true, + "enableGroupCallEventMessages": true, + "enableGroupCallUpgradeMessage": false, + "enableGroupCallMeetupGeneration": false, + }, + "links": { + "conversationEnd": l(1, "/conversation/conversationEnd/"), + "conversationUpdate": l(2, "/conversation/conversationUpdate/"), + "localParticipantUpdate": l(3, "/conversation/localParticipantUpdate/"), + "addParticipantSuccess": l(4, "/conversation/addParticipantSuccess/"), + "addParticipantFailure": l(5, "/conversation/addParticipantFailure/"), + "addModalitySuccess": l(6, "/conversation/addModalitySuccess/"), + "addModalityFailure": l(7, "/conversation/addModalityFailure/"), + "confirmUnmute": l(8, "/conversation/confirmUnmute/"), + "receiveMessage": l(9, "/conversation/receiveMessage/"), + }, + }, + "groupContext": null, + "groupChat": {"threadId": thread, "messageId": live.message}, + "contentSharing": null, + "participants": {"from": me.json(), "to": []}, + "meetingData": null, + "meetingPreferences": {"shouldResurrect": "Resurrect"}, + "endpointState": {"endpointStateSequenceNumber": 0, "state": {"isMuted": true}}, + "capabilities": null, + "endpointCapabilities": ENDPOINT_CAPABILITIES, + "clientEndpointCapabilities": null, + "endpointMetadata": {}, + "meetingInfo": live.meeting_info, + "callInvitation": { + "callModalities": [], + "replaces": null, + "transferor": null, + "clientTransferContext": null, + "customContext": null, + "emergencyContent": null, + "locationContent": null, + "networkContent": null, + "areaContent": null, + "pstnContent": {"emergencyCallCountry": "", "platformName": CLIENT, "publicApiCall": false}, + "links": { + "progress": l(10, "/call/progress/"), + "mediaAnswer": l(11, "/call/mediaAnswer/"), + "acceptance": l(12, "/call/acceptance/"), + "redirection": l(13, "/call/redirection/"), + "end": l(14, "/call/end/"), + }, + "clientContentForMediaController": {"controlVideoStreaming": l(15, "/call/controlVideoStreaming/"), "csrcInfo": l(16, "/call/csrcInfo/")}, + "mediaContent": {"contentType": "application/sdp-ngc-1.0", "blob": offer, "clientLocation": CLIENT_LOCATION, "mediaLegId": media_leg}, + }, + }) +} + +/// The `leave` link of a conversation (the join's answer or a read of the conversation, docs +/// 10.7), and the roster in it, if it has one. +pub fn conversation(body: &[u8], me: &str, endpoint: &str) -> Result<(String, Option), ProtoError> { + let v = parse_json(body)?; + let leave = v.pointer("/links/leave").and_then(Value::as_str).filter(|url| on_service(url)); + let leave = leave.ok_or_else(|| incompatible("links.leave"))?.to_string(); + let roster = match v.get("roster") { + Some(roster) => Some(read_roster(roster, me, endpoint)?), + None => None, + }; + Ok((leave, roster)) +} + +/// The roster of a conversation (`roster.participants`, keyed by MRI; "Live results" 15-18): the +/// user's entry lists its endpoints by id, and the captions bot (a `28:` MRI) has +/// `processingModes.closedCaptions.state` `Active` while captions are on (docs 10.7). +fn read_roster(roster: &Value, me: &str, endpoint: &str) -> Result { + let participants = roster.get("participants").and_then(Value::as_object).ok_or_else(|| incompatible("roster.participants"))?; + let mut user_elsewhere = false; + let mut captions = false; + for (mri, entry) in participants { + if mri.eq_ignore_ascii_case(me) { + let endpoints = entry.get("endpoints").and_then(Value::as_object).ok_or_else(|| incompatible("roster.participants.endpoints"))?; + user_elsewhere |= endpoints.keys().any(|id| !id.eq_ignore_ascii_case(endpoint)); + } else if mri.starts_with("28:") { + captions |= captions_active(entry, 0); + } + } + Ok(Roster { user_elsewhere, captions }) +} + +/// A `processingModes.closedCaptions.state` of `Active` anywhere in a bot's entry, as the probe +/// found it ("Live results" 15), within a few levels. +fn captions_active(v: &Value, depth: u8) -> bool { + if depth > 8 { + return false; + } + match v { + Value::Object(map) => { + map.get("processingModes").and_then(|modes| modes.pointer("/closedCaptions/state")).and_then(Value::as_str) == Some("Active") + || map.values().any(|v| captions_active(v, depth + 1)) + } + Value::Array(items) => items.iter().any(|v| captions_active(v, depth + 1)), + _ => false, + } +} + +/// The body of `leave` (docs 10.7): open_mess's endpoint, ended by the user. +pub fn leave(me: &Me<'_>) -> Value { + json!({"participants": {"from": me.json()}, "callTransactionEnd": {"code": 0, "subCode": 0, "phrase": "LOCAL_USER_INITIATED"}}) +} + +/// The acknowledgement of the call's acceptance ("Live results" 15). +pub fn acknowledgement() -> Value { + json!({"callAcceptanceAcknowledgement": {"links": {}}}) +} + +/// A call callback, by its path after `callAgent//`. +#[derive(Debug, PartialEq, Eq)] +pub enum Call { + /// `/call/acceptance/`: the SDP answer, and where to acknowledge it. + Acceptance { answer: String, acknowledgement: Option }, + /// `/conversation/rosterUpdate/`: the roster changed; the module reads it whole. + Roster, + /// `/conversation/conversationEnd/` (`meeting`, the meeting is over) or `/call/end/` (the + /// service ended open_mess's leg), with the end's code and sub-code for the log (docs 10.7). + Ended { meeting: bool, code: Option, sub_code: Option }, + /// Anything else, or another session's: not read. + Other, +} + +/// What a callback of the session `session` is (docs 10.7). +pub fn callback(url: &str, body: &str, session: &str) -> Result { + let Some(rest) = url.split_once("/callAgent/").map(|(_, rest)| rest) else { return Ok(Call::Other) }; + let mut parts = rest.splitn(3, '/'); + if parts.next() != Some(session) { + return Ok(Call::Other); + } + let path = parts.nth(1).map(|path| format!("/{path}")).unwrap_or_default(); + match path.as_str() { + "/call/acceptance/" => { + let v: Value = serde_json::from_str(body).map_err(|_| incompatible("callAcceptance"))?; + let answer = v + .pointer("/callAcceptance/mediaContent/blob") + .and_then(Value::as_str) + .ok_or_else(|| incompatible("callAcceptance.mediaContent.blob"))?; + let acknowledgement = + v.pointer("/callAcceptance/links/acknowledgement").and_then(Value::as_str).filter(|url| on_service(url)).map(str::to_string); + Ok(Call::Acceptance { answer: answer.to_string(), acknowledgement }) + } + "/conversation/rosterUpdate/" => Ok(Call::Roster), + "/call/end/" | "/conversation/conversationEnd/" => { + let meeting = path == "/conversation/conversationEnd/"; + let v: Value = serde_json::from_str(body).unwrap_or(Value::Null); + let end = v.get(if meeting { "conversationEnd" } else { "callEnd" }); + let number = |key: &str| end.and_then(|end| end.get(key)).and_then(Value::as_i64); + Ok(Call::Ended { meeting, code: number("code"), sub_code: number("subCode") }) + } + _ => Ok(Call::Other), + } +} + +/// A request to the conversation service with the web client's headers ("Live results" 14), less +/// the skype token, which the caller adds. `chain` is one id for the whole call. +pub fn service(method: &str, url: &str, body: Option<&Value>, chain: &str, message: &str, region: Option<(&str, &str)>) -> HttpRequest { + let mut headers = vec![ + ("Content-Type", "application/json".to_string()), + ("X-Microsoft-Skype-Chain-ID", chain.to_string()), + ("X-Microsoft-Skype-Message-ID", message.to_string()), + ("X-Microsoft-Skype-Client", CLIENT.to_string()), + ("MS-Teams-Ring", "general".to_string()), + ]; + if let Some((region, partition)) = region { + headers.extend([("MS-Teams-Region", region.to_string()), ("MS-Teams-Partition", partition.to_string())]); + } + HttpRequest { + method: method.to_string(), + url: url.to_string(), + headers: headers.into_iter().map(|(name, value)| (name.to_string(), value)).collect(), + body: body.map(|b| b.to_string().into_bytes()).unwrap_or_default(), + } +} + +/// What the module stores of a leg before it joins, to leave it from another instance (spec 4.3): +/// no content, no token. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Record { + pub controller: String, + pub endpoint: String, + pub participant: String, + pub at_ms: i64, +} + +/// The records of an account, as stored. +pub fn encode_records(records: &[Record]) -> Vec { + let start = records.len().saturating_sub(MAX_RECORDS); + let items: Vec = records + .iter() + .skip(start) + .map(|r| json!({"controller": r.controller, "endpoint": r.endpoint, "participant": r.participant, "at_ms": r.at_ms})) + .collect(); + Value::Array(items).to_string().into_bytes() +} + +/// Stored records; ones that cannot be read (or name a URL the module would not send a token to) +/// are left out. +pub fn decode_records(bytes: &[u8]) -> Vec { + let Ok(Value::Array(items)) = serde_json::from_slice::(bytes) else { return Vec::new() }; + items + .iter() + .filter_map(|item| { + let text = |key: &str| item.get(key).and_then(Value::as_str).map(str::to_string); + Some(Record { + controller: text("controller").filter(|u| on_service(u))?, + endpoint: text("endpoint")?, + participant: text("participant")?, + at_ms: item.get("at_ms")?.as_i64()?, + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + const ME: &str = "8:orgid:00000000-0000-0000-0000-000000000001"; + const ENDPOINT: &str = "11111111-1111-4111-8111-111111111111"; + const THREAD: &str = "19:meeting_c3ludGhldGlj@thread.v2"; + const CONTROLLER: &str = "https://api.flightproxy.teams.microsoft.com/api/v2/ep/conv.example/conv/abc"; + + /// A data channel fragment as docs 10.7 frames it. + fn packet(start: bool, id: u8, seq: u16, remaining: u8, payload: &[u8]) -> Vec { + let mut b = vec![0x10, 11, if start { 0x80 } else { 0 } | id]; + b.extend(seq.to_be_bytes()); + b.push(remaining); + b.extend(7u32.to_be_bytes()); + b.push(0); + b.extend(payload); + b + } + + fn caption(text: &str, last: bool) -> Vec { + let results = json!({"recognitionResults": [ + {"isFinal": false, "text": "partial", "displayName": "Speaker"}, + {"isFinal": last, "text": text, "displayName": "Speaker", "spokenLanguage": "pl-pl", "userId": "8:orgid:x"}, + ], "events": [], "version": 1}); + packet(true, 3, 1, 0, results.to_string().as_bytes()) + } + + fn live(now: i64) -> Session { + let mut s = Session::new(THREAD, "dG9rZW4", now); + s.answered(); + s.channel_open(now); + assert_eq!(s.roster(&Roster { user_elsewhere: true, captions: true }, now), None); + assert_eq!(s.due(now + CAPTIONS_WAIT_MS), None); + s + } + + #[test] + fn final_captions_are_paged_with_cursor_and_language() { + let mut s = live(0); + assert_eq!(s.data(&[caption("one", true), caption("not yet", false), caption("two", true)], 0, 1_800_000_000_000), None); + let page = s.read(None, None, 65536, 1).unwrap(); + assert_eq!(page["state"], "live"); + assert_eq!(page["captions_on"], true); + assert_eq!(page["next_cursor"], 2); + assert_eq!(page["language"], "pl-pl"); + assert_eq!(page["reason"], Value::Null); + assert_eq!( + page["untrusted"]["cues"], + json!([ + {"at": "2027-01-15T08:00:00Z", "speaker": "Speaker", "text": "one"}, + {"at": "2027-01-15T08:00:00Z", "speaker": "Speaker", "text": "two"}, + ]) + ); + let next = s.read(Some("dG9rZW4"), Some(2), 65536, 2).unwrap(); + assert_eq!((next["next_cursor"].as_u64(), next["untrusted"]["cues"].as_array().map(Vec::len)), (Some(2), Some(0))); + assert_eq!(next["language"], Value::Null); + // Another session's token: refused, so two sessions never mix. + assert_eq!(s.read(Some("b3RoZXI"), Some(0), 65536, 3), Err(SessionChanged)); + } + + #[test] + fn a_page_holds_at_least_one_cue_and_stays_in_its_budget() { + let mut s = live(0); + let long = "x".repeat(5000); + s.data(&[caption(&long, true), caption("b", true), caption("c", true)], 0, 0); + let one = s.read(None, Some(0), 4096, 0).unwrap(); + let text = one["untrusted"]["cues"][0]["text"].as_str().unwrap(); + assert_eq!((text.chars().count(), text.ends_with('\u{2026}')), (MAX_TEXT, true)); + assert_eq!(one["next_cursor"], 1); + let rest = s.read(None, Some(1), 4096, 0).unwrap(); + assert_eq!(rest["next_cursor"], 3); + } + + #[test] + fn fragments_are_put_together_and_bad_frames_counted() { + let mut s = live(0); + let payload = json!({"recognitionResults": [{"isFinal": true, "text": "joined", "displayName": "A"}]}).to_string(); + let (a, b) = payload.split_at(10); + let messages = [packet(true, 3, 5, 1, a.as_bytes()), packet(false, 3, 6, 0, b.as_bytes()), vec![0x20, 0], packet(true, 24, 1, 0, b"rtt")]; + assert_eq!(s.data(&messages, 2, 0), None); + let page = s.read(None, None, 65536, 0).unwrap(); + assert_eq!(page["untrusted"]["cues"][0]["text"], "joined"); + // Two dropped by the host and one frame that could not be read. + assert_eq!(page["missed"], 3); + // A fragment out of sequence breaks its message. + let mut s = live(0); + s.data(&[packet(true, 3, 5, 1, b"{"), packet(false, 3, 9, 0, b"}")], 0, 0); + assert_eq!(s.read(None, None, 65536, 0).unwrap()["missed"], 1); + } + + #[test] + fn a_hundred_unreadable_messages_in_a_row_end_the_session() { + let mut s = live(0); + let junk: Vec> = (0..99).map(|_| packet(true, 3, 1, 0, b"not json")).collect(); + assert_eq!(s.data(&junk, 0, 0), None); + assert_eq!(s.data(&[caption("fine", true)], 0, 0), None); + assert_eq!(s.data(&junk, 0, 0), None); + assert_eq!(s.data(&[packet(true, 3, 1, 0, b"[1]")], 0, 0), Some(Reason::Protocol)); + } + + #[test] + fn the_oldest_cues_go_and_unread_ones_count_as_missed() { + let mut buffer = Buffer::default(); + let cue = |n: usize| Cue { at_ms: 0, speaker: None, text: n.to_string(), language: None, bytes: 1 }; + for n in 0..MAX_CUES { + buffer.push(cue(n)); + } + buffer.page(0, 100); + let read = buffer.read_to; + for n in 0..10 { + buffer.push(cue(MAX_CUES + n)); + } + assert_eq!(buffer.first, 10); + // The ones read before they went are not missed. + assert_eq!(buffer.missed, 10 - read); + let (items, next, _) = buffer.page(0, 100); + assert_eq!((items[0]["text"].as_str(), next), (Some("10"), 10 + items.len() as u64)); + // Bytes too: a cue over the space left pushes out old ones. + let mut buffer = Buffer::default(); + buffer.push(Cue { bytes: MAX_BUFFER_BYTES - 10, ..cue(0) }); + buffer.push(Cue { bytes: 20, ..cue(1) }); + assert_eq!((buffer.first, buffer.bytes), (1, 20)); + } + + #[test] + fn deadlines_end_the_session_with_their_reasons() { + // No answer within the join budget, then no data channel after it. + let mut s = Session::new(THREAD, "t", 0); + assert_eq!((s.due(JOIN_MS - 1), s.due(JOIN_MS)), (None, Some(Due::End(Reason::Protocol)))); + let mut s = Session::new(THREAD, "t", 0); + s.answered(); + assert_eq!(s.due(JOIN_MS), Some(Due::End(Reason::MediaFailed))); + // Nobody turned captions on within 30 s of the data channel opening. + let mut s = Session::new(THREAD, "t", 0); + s.channel_open(1000); + s.roster(&Roster { user_elsewhere: true, captions: false }, 1000); + assert_eq!(s.next_at(), Some(1000 + CAPTIONS_WAIT_MS)); + assert_eq!(s.due(1000 + CAPTIONS_WAIT_MS), Some(Due::End(Reason::NoCaptions))); + // On when joining but off before the 30 s were up: the end at 30 s (Codex review of 96070fd). + let mut s = Session::new(THREAD, "t", 0); + s.roster(&Roster { user_elsewhere: true, captions: true }, 0); + s.channel_open(1000); + s.roster(&Roster { user_elsewhere: true, captions: false }, 2000); + assert_eq!(s.due(1000 + CAPTIONS_WAIT_MS), Some(Due::End(Reason::NoCaptions))); + // Captions turned off later in the session: 5 minutes, then the end. + let mut s = live(0); + s.roster(&Roster { user_elsewhere: true, captions: false }, 40_000); + assert_eq!(s.read(None, None, 4096, 40_000).unwrap()["captions_on"], false); + assert_eq!(s.next_at(), Some(40_000 + CAPTIONS_OFF_MS)); + assert_eq!(s.due(40_000 + CAPTIONS_OFF_MS - 1), None); + s.roster(&Roster { user_elsewhere: true, captions: true }, 200_000); + assert_eq!(s.due(40_000 + CAPTIONS_OFF_MS), None); + let mut s = live(0); + s.roster(&Roster { user_elsewhere: true, captions: false }, 40_000); + assert_eq!(s.due(40_000 + CAPTIONS_OFF_MS), Some(Due::End(Reason::NoCaptions))); + // Silence keeps the session; not reading it for 10 minutes ends it, and 3 hours do. + let mut s = live(0); + s.read(None, None, 4096, 9 * 60_000).unwrap(); + assert_eq!(s.due(15 * 60_000), None); + assert_eq!(s.due(19 * 60_000), Some(Due::End(Reason::Idle))); + let mut s = live(0); + for minute in (5..=180).step_by(5) { + s.read(None, None, 4096, minute * 60_000).unwrap(); + } + assert_eq!(s.due(TIME_LIMIT_MS), Some(Due::End(Reason::TimeLimit))); + // An ended session is read for 15 minutes, then forgotten. + let mut s = live(0); + s.end(Reason::Left, 1000); + assert_eq!((s.state(), s.read(None, None, 4096, 2000).unwrap()["reason"].as_str()), (State::Ended, Some("left"))); + assert_eq!((s.due(1000 + KEEP_MS - 1), s.due(1000 + KEEP_MS)), (None, Some(Due::Forget))); + s.end(Reason::Protocol, 3000); + assert_eq!(s.state(), State::Ended); + } + + #[test] + fn the_user_must_be_in_the_meeting_on_another_endpoint() { + let mut s = Session::new(THREAD, "t", 0); + assert_eq!(s.roster(&Roster { user_elsewhere: false, captions: true }, 0), Some(Reason::UserNotInMeeting)); + let mut s = live(0); + assert_eq!(s.roster(&Roster { user_elsewhere: false, captions: true }, 5), Some(Reason::UserLeft)); + s.end(Reason::UserLeft, 5); + assert_eq!(s.state(), State::Ended); + s.end(Reason::NoCaptions, 6); + let mut failed = Session::new(THREAD, "t", 0); + failed.end(Reason::UserNotInMeeting, 1); + assert_eq!(failed.state(), State::Failed); + } + + #[test] + fn while_trouter_is_down_the_roster_is_read_every_10_s() { + let mut s = live(0); + s.down(1000); + // A read that finds the user keeps the reads going and the captions out (Codex review of 96070fd). + assert_eq!(s.roster(&Roster { user_elsewhere: true, captions: true }, 11_000), None); + assert_eq!(s.due(21_000), Some(Due::Roster)); + s.data(&[caption("lost", true)], 0, 21_000); + assert_eq!(s.read(None, None, 4096, 21_000).unwrap()["missed"], 1); + assert_eq!(s.roster(&Roster { user_elsewhere: false, captions: true }, 21_000), Some(Reason::UserLeft)); + // Captions back on during the gap: the 5 minutes of captions off stop counting. + let mut s = live(0); + s.roster(&Roster { user_elsewhere: true, captions: false }, 40_000); + s.down(50_000); + s.roster(&Roster { user_elsewhere: true, captions: true }, 60_000); + assert_eq!(s.due(40_000 + CAPTIONS_OFF_MS), Some(Due::Roster)); + assert_eq!(s.read(None, None, 4096, 60_000).unwrap()["captions_on"], Value::Null); + // Back up: the next read lets captions in again. + let mut s = live(0); + s.down(1000); + s.up(); + assert_eq!(s.roster(&Roster { user_elsewhere: true, captions: true }, 2000), None); + s.data(&[caption("kept", true)], 0, 2000); + assert_eq!(s.read(None, None, 4096, 2000).unwrap()["untrusted"]["cues"][0]["text"], "kept"); + assert_eq!(s.next_at(), Some(2000 + IDLE_MS)); + } + + #[test] + fn a_gap_drops_captions_until_the_roster_is_read_again() { + let mut s = live(0); + s.pause(1000); + assert_eq!(s.read(None, None, 4096, 1000).unwrap()["captions_on"], Value::Null); + s.data(&[caption("lost", true)], 0, 1000); + assert_eq!(s.read(None, None, 4096, 1000).unwrap()["missed"], 1); + assert_eq!(s.due(1000 + ROSTER_POLL_MS), Some(Due::Roster)); + s.roster_polled(1000 + ROSTER_POLL_MS); + assert_eq!(s.next_at(), Some(1000 + 2 * ROSTER_POLL_MS)); + // The user left meanwhile: the session ends at the next read of the roster. + assert_eq!(s.roster(&Roster { user_elsewhere: false, captions: true }, 21_000), Some(Reason::UserLeft)); + let mut s = live(0); + s.pause(1000); + s.roster(&Roster { user_elsewhere: true, captions: true }, 2000); + s.data(&[caption("kept", true)], 0, 2000); + assert_eq!(s.read(None, None, 4096, 2000).unwrap()["untrusted"]["cues"][0]["text"], "kept"); + assert_eq!(s.due(60_000), None); + } + + #[test] + fn the_call_store_gives_the_meetings_call_on_the_service_host_only() { + let item = |url: &str, thread: &str| { + json!({"conversationController": url, "correlationId": "c", "groupContext": {"messageId": "1700000000000", "threadId": thread}, + "meetingInfo": {"organizerId": "o", "tenantId": "t"}, "userJoinTime": "x"}) + }; + let body = json!([item(CONTROLLER, "19:other@thread.v2"), item(CONTROLLER, THREAD)]).to_string(); + let live = live_call(body.as_bytes(), THREAD).unwrap().unwrap(); + assert_eq!((live.controller.as_str(), live.message.as_str()), (CONTROLLER, "1700000000000")); + assert_eq!(live.meeting_info, json!({"organizerId": "o", "tenantId": "t"})); + assert_eq!(live_call(b"[]", THREAD), Ok(None)); + assert_eq!(live_call(br#"{"conversations": []}"#, THREAD), Err(incompatible("$"))); + for url in [ + "http://api.flightproxy.teams.microsoft.com/x", + "https://evil.example/x", + "https://u@api.flightproxy.teams.microsoft.com/x", + "https://api.flightproxy.teams.microsoft.com:8443/x", + ] { + let body = json!([item(url, THREAD)]).to_string(); + assert_eq!(live_call(body.as_bytes(), THREAD), Err(incompatible("conversationController")), "{url}"); + } + } + + fn me() -> Me<'static> { + Me { mri: ME, name: "Synthetic Person One", endpoint: ENDPOINT, participant: "p" } + } + + #[test] + fn the_join_is_the_probes_data_only_body() { + let live = Live { controller: CONTROLLER.to_string(), meeting_info: json!({"organizerId": "o"}), message: "0".to_string() }; + let links = Links { surl: "https://trouter.example/v4/f/abc/", session: "s1", tag: 0xffff_fffe }; + let body = join(&me(), &links, &live, THREAD, "v=0\r\n", "leg"); + assert_eq!( + body["conversationRequest"]["roster"]["rosterUpdate"], + "https://trouter.example/v4/f/abc/callAgent/s1/fffffffe/conversation/rosterUpdate/" + ); + assert_eq!(body["callInvitation"]["links"]["acceptance"], "https://trouter.example/v4/f/abc/callAgent/s1/0000000a/call/acceptance/"); + assert_eq!(body["callInvitation"]["callModalities"], json!([])); + assert_eq!(body["callInvitation"]["mediaContent"]["blob"], "v=0\r\n"); + assert_eq!(body["participants"]["from"]["endpointId"], ENDPOINT); + assert_eq!(body["endpointState"]["state"]["isMuted"], true); + assert_eq!((body["groupChat"]["threadId"].as_str(), body["meetingInfo"]["organizerId"].as_str()), (Some(THREAD), Some("o"))); + assert!(body.get("payload").is_none()); + let no_slash = Links { surl: "https://trouter.example/v4/f/abc", ..links }; + assert!( + join(&me(), &no_slash, &live, THREAD, "", "leg")["conversationRequest"]["links"]["conversationEnd"] + .as_str() + .is_some_and(|l| l.starts_with("https://trouter.example/v4/f/abc/callAgent/s1/")) + ); + } + + #[test] + fn the_roster_tells_the_users_other_endpoint_and_the_captions_bot() { + let conversation = |endpoints: Value, bot: &str| { + json!({"links": {"leave": format!("{CONTROLLER}/leave")}, "roster": {"participants": { + ME.to_uppercase(): {"state": "active", "endpoints": endpoints}, + "8:orgid:someone-else": {"endpoints": {"x": {}}}, + "28:bot": {"endpoints": {"b": {"endpointMetadata": {"processingModes": {"closedCaptions": {"state": bot}}}}}}, + }}}) + .to_string() + }; + let both = conversation(json!({ENDPOINT: {}, "official": {}}), "Active"); + let (leave, roster) = super::conversation(both.as_bytes(), ME, ENDPOINT).unwrap(); + assert_eq!(leave, format!("{CONTROLLER}/leave")); + assert_eq!(roster, Some(Roster { user_elsewhere: true, captions: true })); + let alone = conversation(json!({ENDPOINT: {}}), "Inactive"); + assert_eq!(super::conversation(alone.as_bytes(), ME, ENDPOINT).unwrap().1, Some(Roster { user_elsewhere: false, captions: false })); + let no_roster = json!({"links": {"leave": format!("{CONTROLLER}/leave")}}).to_string(); + assert_eq!(super::conversation(no_roster.as_bytes(), ME, ENDPOINT).unwrap().1, None); + let foreign = json!({"links": {"leave": "https://evil.example/leave"}}).to_string(); + assert_eq!(super::conversation(foreign.as_bytes(), ME, ENDPOINT), Err(incompatible("links.leave"))); + let listed = conversation(json!([ENDPOINT]), "Active"); + assert!(super::conversation(listed.as_bytes(), ME, ENDPOINT).is_err()); + } + + #[test] + fn callbacks_are_told_apart_by_path_and_session() { + let base = "https://trouter.example/v4/f/abc/callAgent/s1/0000000c"; + let acceptance = + json!({"callAcceptance": {"mediaContent": {"blob": "v=0"}, "links": {"acknowledgement": format!("{CONTROLLER}/ack")}}}).to_string(); + assert_eq!( + callback(&format!("{base}/call/acceptance/"), &acceptance, "s1"), + Ok(Call::Acceptance { answer: "v=0".to_string(), acknowledgement: Some(format!("{CONTROLLER}/ack")) }) + ); + let foreign_ack = + json!({"callAcceptance": {"mediaContent": {"blob": "v=0"}, "links": {"acknowledgement": "https://evil.example/ack"}}}).to_string(); + assert_eq!( + callback(&format!("{base}/call/acceptance/"), &foreign_ack, "s1"), + Ok(Call::Acceptance { answer: "v=0".to_string(), acknowledgement: None }) + ); + assert!(callback(&format!("{base}/call/acceptance/"), "{}", "s1").is_err()); + assert_eq!(callback(&format!("{base}/conversation/rosterUpdate/"), "{}", "s1"), Ok(Call::Roster)); + let ended = json!({"callEnd": {"code": 410, "subCode": 5000, "phrase": "x"}}).to_string(); + assert_eq!(callback(&format!("{base}/call/end/"), &ended, "s1"), Ok(Call::Ended { meeting: false, code: Some(410), sub_code: Some(5000) })); + assert_eq!( + callback(&format!("{base}/conversation/conversationEnd/"), "", "s1"), + Ok(Call::Ended { meeting: true, code: None, sub_code: None }) + ); + assert_eq!(callback(&format!("{base}/conversation/receiveMessage/"), "{}", "s1"), Ok(Call::Other)); + assert_eq!(callback(&format!("{base}/call/end/"), "", "s2"), Ok(Call::Other)); + assert_eq!(callback("https://trouter.example/messaging", "{}", "s1"), Ok(Call::Other)); + } + + #[test] + fn records_round_trip_and_keep_the_newest() { + let record = |n: i64| Record { controller: CONTROLLER.to_string(), endpoint: ENDPOINT.to_string(), participant: format!("p{n}"), at_ms: n }; + let records: Vec = (0..10).map(record).collect(); + let back = decode_records(&encode_records(&records)); + assert_eq!(back, records[2..].to_vec()); + let foreign = br#"[{"controller": "https://evil.example/x", "endpoint": "e", "participant": "p", "at_ms": 1}, {"endpoint": "e"}]"#; + assert_eq!(decode_records(foreign), Vec::new()); + assert_eq!(decode_records(b"junk"), Vec::new()); + } + + #[test] + fn service_requests_carry_the_clients_headers() { + let request = service("POST", CONTROLLER, Some(&json!({"a": 1})), "chain", "message", Some(("emea", "emea01"))); + let header = |name: &str| request.headers.iter().find(|(n, _)| n == name).map(|(_, v)| v.as_str()); + assert_eq!((header("X-Microsoft-Skype-Chain-ID"), header("MS-Teams-Partition")), (Some("chain"), Some("emea01"))); + assert_eq!(header("X-Microsoft-Skype-Client"), Some(CLIENT)); + assert_eq!(request.body, br#"{"a":1}"#); + assert!(service("GET", CONTROLLER, None, "c", "m", None).headers.iter().all(|(n, _)| n != "MS-Teams-Region")); + assert_eq!(call_store(ME).url, format!("https://{SERVICE_HOST}/api/v2/ep/api.userstore.skype.com/api/v1/user/{ME}/conversations")); + } +} diff --git a/modules/teams/src/component.rs b/modules/teams/src/component.rs index 8d1b94b..5a352e2 100644 --- a/modules/teams/src/component.rs +++ b/modules/teams/src/component.rs @@ -37,23 +37,37 @@ impl module::Guest for Teams { .into_iter() .map(|t| types::ToolDecl { name: t.name, description: t.description, input_schema: t.input_schema, writes: t.writes }) .collect(), + media: m.media.map(|peers| types::MediaAccess { peers }), } } fn sign_in() -> Result { api::begin(); - api::sign_in() + let info = api::sign_in()?; + // Signed in again after a lost session: meetings it could not leave are left now. + crate::live::restore(&info.id); + Ok(info) } fn restore(account: String) -> Result<(), types::ModuleError> { api::begin(); - api::restore(&account) + api::restore(&account)?; + crate::live::restore(&account); + Ok(()) } fn sign_out(account: String) { + api::begin(); + crate::live::sign_out(&account); api::sign_out(&account); } + /// Leaves the account's meeting, within the host's 5 s. + fn end_sessions(account: String) { + api::begin_within(std::time::Duration::from_secs(4)); + crate::live::end_sessions(&account); + } + fn call(account: String, tool: String, args_json: String) -> Result { api::begin(); crate::tools::call(&account, &tool, &args_json) @@ -71,14 +85,32 @@ impl module::Guest for Teams { } impl events::Guest for Teams { - fn on_ws_message(_conn: u32, _text: String) {} + fn on_ws_message(conn: u32, text: String) { + api::begin(); + crate::live::ws_message(conn, &text); + } - fn on_ws_closed(_conn: u32, _reason: String) {} + fn on_ws_closed(conn: u32, _reason: String) { + api::begin(); + crate::live::ws_closed(conn); + } + + fn on_rtc_data(conn: u32, messages: Vec>, dropped: u32) { + api::begin(); + crate::live::rtc_data(conn, &messages, dropped); + } + + fn on_rtc_state(conn: u32, state: String) { + api::begin(); + crate::live::rtc_state(conn, &state); + } /// The poll (`poll`): every signed-in account, each round starting with the next one so a /// slow account cannot keep the others from their turn, then the timer again. fn on_timer(id: u32) { if id != crate::poll::TIMER { + api::begin(); + crate::live::timer_fired(id); return; } api::timer_fired(); diff --git a/modules/teams/src/lib.rs b/modules/teams/src/lib.rs index 550ae69..90edb81 100644 --- a/modules/teams/src/lib.rs +++ b/modules/teams/src/lib.rs @@ -5,16 +5,19 @@ //! of `wit/module.wit` when built for wasm32-wasip2. Protocol facts: `docs/protocols/teams.md`. pub mod authsvc; +pub mod captions; pub mod chat; pub mod http; pub mod manifest; pub mod meetings; pub mod poll; pub mod retry; +pub mod sdp; pub mod search; pub mod session; pub mod text; pub mod time; +pub mod trouter; pub mod unread; pub mod writing; @@ -24,6 +27,8 @@ mod api; #[cfg(target_arch = "wasm32")] mod component; #[cfg(target_arch = "wasm32")] +mod live; +#[cfg(target_arch = "wasm32")] mod tools; /// Why a response could not be decoded or a request was refused. diff --git a/modules/teams/src/live.rs b/modules/teams/src/live.rs new file mode 100644 index 0000000..091fab1 --- /dev/null +++ b/modules/teams/src/live.rs @@ -0,0 +1,690 @@ +//! Live-captions sessions in the module instance (spec 2026-10-29-live-captions, 4 and 5.3): the +//! three tools, the Trouter socket, the media connection, the timers and the session records, +//! around the pure `captions`, `trouter` and `sdp`. + +use std::cell::RefCell; +use std::collections::HashMap; + +use serde_json::{Value, json}; + +use crate::api::{self, Service}; +use crate::captions::{self, Call, Due, Links, Live, Me, Reason, Record, Session as Captions}; +use crate::component::bindings::open_mess::module::http::{HttpError, Response}; +use crate::component::bindings::open_mess::module::types::ModuleError; +use crate::component::bindings::open_mess::module::{host, rtc, storage, websocket}; +use crate::sdp; +use crate::session::Session; +use crate::trouter::{Incoming, Tick, Trouter}; + +/// Sessions' timers: one per account, from this id on (the poll's is 1). +const TIMER_BASE: u32 = 100; +/// At most this many sessions at once in the module, as the host's media connections (spec 5.2). +const MAX_SESSIONS: usize = 4; +/// The call store may lag a fresh join (spec 4.1, R15): two more reads, this far apart. +const STORE_READS: usize = 3; +const STORE_WAIT_MS: u32 = 5000; +const LABEL: &str = "main-channel"; +const NOT_LISTED: &str = "Teams does not list you in this meeting yet; join it in Teams first, or try again in a moment"; +const CHANGED: &str = "the captions session changed, read again without session and cursor"; + +/// One account's session and the connections it holds. +struct Leg { + account: String, + captions: Captions, + trouter: Trouter, + ws: Option, + rtc: Option, + live: Live, + endpoint: String, + participant: String, + /// The session id in the callback links. + callbacks: String, + tag: u32, + chain: String, + media_leg: String, + leave: Option, + /// The join may have reached the service: the session ends with `leave`. + joined: bool, + registered: bool, +} + +thread_local! { + static LEGS: RefCell> = RefCell::new(HashMap::new()); + /// Accounts whose session records still wait for a `leave`: the next try and the last one. + static RETRIES: RefCell> = RefCell::new(HashMap::new()); + /// The account of each session timer, by its place after `TIMER_BASE`. + static SLOTS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +fn records_key(account: &str) -> String { + format!("captions-legs:{account}") +} + +fn records(account: &str) -> Vec { + storage::get(&records_key(account)).map(|bytes| captions::decode_records(&bytes)).unwrap_or_default() +} + +/// Whether the records were stored. +fn store_records(account: &str, records: &[Record]) -> bool { + let key = records_key(account); + if records.is_empty() { + storage::delete(&key); + return true; + } + let stored = storage::set(&key, &captions::encode_records(records)).is_ok(); + if !stored { + host::log(1, "a live-captions session record was not stored"); + } + stored +} + +/// open_mess's own endpoint id for the account (spec 5.3): made once, kept in storage, never the +/// official client's. +fn endpoint(account: &str) -> String { + let key = format!("captions-endpoint:{account}"); + if let Some(id) = storage::get(&key).and_then(|bytes| String::from_utf8(bytes).ok()).filter(|id| id.len() == 36) { + return id; + } + let id = api::uuid(); + if storage::set(&key, id.as_bytes()).is_err() { + host::log(1, "the live-captions endpoint id was not stored"); + } + id +} + +fn timer(account: &str) -> u32 { + SLOTS.with(|slots| { + let mut slots = slots.borrow_mut(); + let at = slots.iter().position(|a| a == account).unwrap_or_else(|| { + slots.push(account.to_string()); + slots.len() - 1 + }); + TIMER_BASE + u32::try_from(at).unwrap_or(0) + }) +} + +fn account_of(find: impl Fn(&Leg) -> bool) -> Option { + LEGS.with(|legs| legs.borrow().values().find(|leg| find(leg)).map(|leg| leg.account.clone())) +} + +fn take(account: &str) -> Option { + LEGS.with(|legs| legs.borrow_mut().remove(account)) +} + +/// Puts a leg back, or forgets it once its cues are gone, and sets its timer. +fn put(leg: Leg, now: i64) { + let account = leg.account.clone(); + if !leg.captions.gone(now) { + LEGS.with(|legs| legs.borrow_mut().insert(account.clone(), leg)); + } + schedule(&account, now); +} + +/// The account's timer, for the earliest thing due in its session and its `leave` retries. +fn schedule(account: &str, now: i64) { + let leg = LEGS.with(|legs| { + legs.borrow().get(account).map(|leg| { + let trouter = if leg.captions.active() { leg.trouter.next() } else { None }; + [trouter, leg.captions.next_at()].into_iter().flatten().min() + }) + }); + let retry = RETRIES.with(|r| r.borrow().get(account).map(|(next, _)| *next)); + if let Some(at) = [leg.flatten(), retry].into_iter().flatten().min() { + let after = u32::try_from(at.saturating_sub(now).clamp(50, i64::from(u32::MAX))).unwrap_or(u32::MAX); + host::set_timer(timer(account), after); + } +} + +/// Runs `f` on the account's leg with its session, outside a tool call (an event; the caller has +/// started the call's budget). +fn event(account: &str, f: impl FnOnce(&mut Leg, &mut Session, i64)) { + let Some(mut leg) = take(account) else { return }; + let now = api::now_ms(); + let done = api::with_session(account, |s| { + f(&mut leg, s, now); + Ok(()) + }); + if done.is_err() { + // No session to send anything with: the connections go, and the record stays for the + // retries, a new sign-in or `restore` (GLM review of 7385fdd). + close(&mut leg); + if leg.captions.active() && leg.joined { + retry_from(account, now); + } + leg.captions.end(Reason::Protocol, now); + } + put(leg, now); +} + +fn text<'a>(args: &'a Value, key: &str) -> Option<&'a str> { + args.get(key).and_then(Value::as_str) +} + +fn conversation_arg(args: &Value) -> Result<&str, ModuleError> { + text(args, "conversation") + .filter(|c| c.starts_with("19:")) + .ok_or_else(|| ModuleError::InvalidArgs("conversation must be a 19: thread id".to_string())) +} + +fn region(s: &Session) -> Option<(&str, &str)> { + s.region.as_ref().map(|(region, partition)| (region.as_str(), partition.as_str())) +} + +fn me<'a>(s: &'a Session, leg: &'a Leg, mri: &'a str) -> Me<'a> { + Me { mri, name: &s.name, endpoint: &leg.endpoint, participant: &leg.participant } +} + +fn describe(e: &HttpError) -> String { + match e { + HttpError::Blocked(why) | HttpError::Network(why) => why.clone(), + HttpError::Timeout => "timed out".to_string(), + HttpError::TooLarge => "too large".to_string(), + HttpError::Throttled(secs) => format!("throttled for {secs} s"), + } +} + +/// `teams_join_captions`, once the user approved it (spec 4.1): the checks, the session and the +/// Trouter socket; the join itself follows in events. +pub fn join(s: &mut Session, args: &Value) -> Result { + let conversation = conversation_arg(args)?.to_string(); + let account = s.account.clone(); + let (busy, active) = LEGS.with(|legs| { + let legs = legs.borrow(); + (legs.get(&account).is_some_and(|leg| leg.captions.active()), legs.values().filter(|leg| leg.captions.active()).count()) + }); + if busy { + return Err(ModuleError::Platform("already reading the captions of another meeting; leave it first".to_string())); + } + if active >= MAX_SESSIONS { + return Err(ModuleError::Platform(format!("connection_limit: at most {MAX_SESSIONS} live-captions sessions at once"))); + } + let mut live = None; + for read in 0..STORE_READS { + if read > 0 && !api::wait(STORE_WAIT_MS) { + break; + } + let body = api::ok(api::conversation(s, &captions::call_store(&s.me()), false)?)?; + live = captions::live_call(&body, &conversation).map_err(api::proto)?; + if live.is_some() { + break; + } + } + let live = live.ok_or_else(|| ModuleError::Platform(NOT_LISTED.to_string()))?; + let now = api::now_ms(); + let endpoint = endpoint(&account); + let mut trouter = Trouter::new(&endpoint, &api::uuid()); + let ws = websocket::open(&trouter.url(&api::uuid()), &[]) + .map_err(|e| ModuleError::Platform(format!("Teams could not be reached: {}", describe(&e))))?; + let token = format!("{:016x}{:016x}", api::random(), api::random()); + let leg = Leg { + account: account.clone(), + captions: Captions::new(&conversation, &token, now), + trouter, + ws: Some(ws), + rtc: None, + live, + endpoint, + participant: api::uuid(), + callbacks: api::uuid(), + tag: u32::try_from(api::random() >> 32).unwrap_or(0), + chain: api::uuid(), + media_leg: api::uuid().replace('-', ""), + leave: None, + joined: false, + registered: false, + }; + put(leg, now); + Ok(json!({"state": "joining"})) +} + +/// `teams_read_captions` (spec 4.2). +pub fn read(s: &mut Session, args: &Value) -> Result { + let conversation = conversation_arg(args)?; + let session = text(args, "session"); + let cursor = match args.get("cursor") { + None => None, + Some(v) => Some(v.as_u64().ok_or_else(|| ModuleError::InvalidArgs("cursor must be a whole number".to_string()))?), + }; + if cursor.is_some() && session.is_none() { + return Err(ModuleError::InvalidArgs("cursor needs session".to_string())); + } + let max_bytes = match args.get("max_bytes") { + None => 65536, + Some(v) => v + .as_u64() + .filter(|n| (4096..=262_144).contains(n)) + .ok_or_else(|| ModuleError::InvalidArgs("max_bytes must be 4096-262144".to_string()))?, + }; + let now = api::now_ms(); + let read = LEGS.with(|legs| { + legs.borrow_mut() + .get_mut(&s.account) + .filter(|leg| leg.captions.conversation == conversation) + .map(|leg| leg.captions.read(session, cursor, usize::try_from(max_bytes).unwrap_or(65536), now)) + }); + match read { + Some(Ok(page)) => { + schedule(&s.account, now); + Ok(page) + } + Some(Err(_)) => Err(ModuleError::InvalidArgs(CHANGED.to_string())), + None => Ok(json!({ + "state": "not_joined", "session": null, "captions_on": null, "next_cursor": 0, "missed": 0, "language": null, + "reason": null, "untrusted": {"cues": []}, + })), + } +} + +/// `teams_leave_captions` (spec 4.3): leaves the meeting of this conversation's session. +pub fn leave(s: &mut Session, args: &Value) -> Result { + let conversation = conversation_arg(args)?; + let now = api::now_ms(); + let Some(mut leg) = take(&s.account) else { return Ok(json!({"state": "not_joined"})) }; + if leg.captions.conversation != conversation { + put(leg, now); + return Ok(json!({"state": "not_joined"})); + } + end(&mut leg, s, Reason::Left, now); + let state = leg.captions.state().name(); + put(leg, now); + Ok(json!({"state": state})) +} + +/// Ends the session: the connections go, the registration is removed, and the meeting is left +/// (spec 4.3). A `leave` that fails is tried again later from the record. +fn end(leg: &mut Leg, s: &mut Session, reason: Reason, now: i64) { + if !leg.captions.active() { + return; + } + host::log(2, &format!("live captions: the session ends ({})", reason.name())); + leg.captions.end(reason, now); + close(leg); + // `leave` first: what others see goes before what only Trouter knows (Codex review of 7385fdd). + let record = Record { controller: leg.live.controller.clone(), endpoint: leg.endpoint.clone(), participant: leg.participant.clone(), at_ms: now }; + if !leg.joined || leave_one(s, &record, leg.leave.as_deref()) { + forget(&leg.account, &leg.participant); + } else { + retry_from(&leg.account, now); + } + if leg.registered && api::has_time() && api::send(s, Service::Chat, &leg.trouter.unregistration(), false).is_err() { + host::log(1, "the live-captions registration was not removed; it lapses by itself"); + } +} + +fn close(leg: &mut Leg) { + if let Some(conn) = leg.rtc.take() { + rtc::close(conn); + } + if let Some(conn) = leg.ws.take() { + websocket::close(conn); + } +} + +fn forget(account: &str, participant: &str) { + let mut all = records(account); + all.retain(|r| r.participant != participant); + store_records(account, &all); +} + +/// Starts the account's `leave` retries, unless they are going on (spec 4.3: every 30 s, for at +/// most 10 minutes). +fn retry_from(account: &str, now: i64) { + RETRIES.with(|r| { + r.borrow_mut() + .entry(account.to_string()) + .or_insert((now.saturating_add(captions::LEAVE_RETRY_MS), now.saturating_add(captions::LEAVE_RETRIES_FOR_MS))); + }); +} + +/// A conversation request with the account's skype token. +fn service(s: &mut Session, leg: &Leg, method: &str, url: &str, body: Option<&Value>, write: bool) -> Result { + let request = captions::service(method, url, body, &leg.chain, &api::uuid(), region(s)); + api::conversation(s, &request, write) +} + +/// One `leave` of a recorded leg: its conversation's `leave` link (read again unless known), as +/// that endpoint. True when the leg is gone: left, or the service no longer knows it. +fn leave_one(s: &mut Session, record: &Record, link: Option<&str>) -> bool { + let mri = s.me(); + let gone = |status: u16| (200..=299).contains(&status) || status == 404 || status == 410; + let request = |method: &str, url: &str, body: Option<&Value>| captions::service(method, url, body, &api::uuid(), &api::uuid(), None); + let link = match link { + Some(link) => link.to_string(), + None => match api::conversation(s, &request("GET", &record.controller, None), false) { + Ok(r) if r.status == 404 || r.status == 410 => return true, + Ok(r) if (200..=299).contains(&r.status) => match captions::conversation(&r.body, &mri, &record.endpoint) { + Ok((link, _)) => link, + Err(_) => return false, + }, + _ => return false, + }, + }; + let body = captions::leave(&Me { mri: &mri, name: &s.name, endpoint: &record.endpoint, participant: &record.participant }); + matches!(api::conversation(s, &request("POST", &link, Some(&body)), true), Ok(r) if gone(r.status)) +} + +/// `leave` for the account's records of legs no session holds (spec 4.3): every one, a stale one +/// once whatever the answer. Whether any is left to try again. +fn leave_records(s: &mut Session, now: i64) -> bool { + let held = LEGS.with(|legs| legs.borrow().get(&s.account).filter(|leg| leg.captions.active()).map(|leg| leg.participant.clone())); + let (mut kept, mut waiting) = (Vec::new(), false); + for record in records(&s.account) { + let stale = now.saturating_sub(record.at_ms) > captions::STALE_RECORD_MS; + if Some(&record.participant) == held.as_ref() { + kept.push(record); + } else if !api::has_time() || (!leave_one(s, &record, None) && !stale) { + waiting = true; + kept.push(record); + } + } + store_records(&s.account, &kept); + waiting +} + +/// After `restore` (daemon start, or a new instance after a crash): the meetings of legs that +/// were never left are left now (spec 4.3). +pub fn restore(account: &str) { + if records(account).is_empty() { + return; + } + let now = api::now_ms(); + match api::with_session(account, |s| Ok(leave_records(s, now))) { + Ok(true) => retry_from(account, now), + Ok(false) => {} + Err(_) => host::log(1, "the meetings of earlier live-captions sessions were not left yet"), + } + schedule(account, now); +} + +/// Before the daemon stops, an update or a module scan: the session ends and leaves (spec 4.3). +pub fn end_sessions(account: &str) { + event(account, |leg, s, now| end(leg, s, Reason::Update, now)); +} + +/// Before the account goes: the session ends and leaves, and every leg still on record gets one +/// more try (spec 4.3), whether a session holds it or not (Codex review of 7385fdd). +pub fn sign_out(account: &str) { + event(account, |leg, s, now| end(leg, s, Reason::Left, now)); + if !records(account).is_empty() { + let now = api::now_ms(); + if api::with_session(account, |s| Ok(leave_records(s, now))).is_err() { + host::log(1, "the meetings of live-captions sessions were not left before sign-out"); + } + } + RETRIES.with(|r| r.borrow_mut().remove(account)); + LEGS.with(|legs| legs.borrow_mut().remove(account)); +} + +pub fn ws_message(conn: u32, text: &str) { + if let Some(account) = account_of(|leg| leg.ws == Some(conn)) { + event(&account, |leg, s, now| frame(leg, s, text, now)); + } +} + +pub fn ws_closed(conn: u32) { + if let Some(account) = account_of(|leg| leg.ws == Some(conn)) { + event(&account, |leg, _, now| { + leg.ws = None; + leg.trouter.lost(now); + leg.captions.down(now); + }); + } +} + +pub fn rtc_data(conn: u32, messages: &[Vec], dropped: u32) { + if let Some(account) = account_of(|leg| leg.rtc == Some(conn)) { + event(&account, |leg, s, now| { + if let Some(reason) = leg.captions.data(messages, dropped, now) { + end(leg, s, reason, now); + } + }); + } +} + +pub fn rtc_state(conn: u32, state: &str) { + if let Some(account) = account_of(|leg| leg.rtc == Some(conn)) { + event(&account, |leg, s, now| match state { + "connected" => { + host::log(2, "live captions: the data channel is open"); + leg.captions.channel_open(now); + } + "failed" | "closed" => { + leg.rtc = None; + end(leg, s, Reason::MediaFailed, now); + } + _ => {} + }); + } +} + +/// A session timer: Trouter's reconnections, pings and renewals, the session's deadlines and the +/// account's `leave` retries. +pub fn timer_fired(id: u32) -> bool { + let Some(account) = id.checked_sub(TIMER_BASE).and_then(|at| SLOTS.with(|slots| slots.borrow().get(usize::try_from(at).ok()?).cloned())) else { + return false; + }; + let retry_due = RETRIES.with(|r| r.borrow().get(&account).copied()); + let now = api::now_ms(); + if let Some((next, until)) = retry_due + && now >= next + { + // Without a session (WAM gives no token now) the records wait, and the retries go on. + let waiting = api::with_session(&account, |s| Ok(leave_records(s, now))).unwrap_or(true); + RETRIES.with(|r| { + let mut r = r.borrow_mut(); + if waiting && now < until { + r.insert(account.clone(), (now.saturating_add(captions::LEAVE_RETRY_MS), until)); + } else { + r.remove(&account); + } + }); + } + if LEGS.with(|legs| legs.borrow().contains_key(&account)) { + event(&account, tick); + } else { + schedule(&account, now); + } + true +} + +fn tick(leg: &mut Leg, s: &mut Session, now: i64) { + while leg.captions.active() { + match leg.trouter.tick(now) { + Some(Tick::Connect) => match websocket::open(&leg.trouter.url(&api::uuid()), &[]) { + Ok(conn) => leg.ws = Some(conn), + Err(_) => { + leg.trouter.lost(now); + leg.captions.down(now); + } + }, + Some(Tick::Send(frame)) => send(leg, &frame, now), + Some(Tick::Register) => register(leg, s, now), + Some(Tick::Failed) => end(leg, s, Reason::Protocol, now), + None => break, + } + } + match leg.captions.due(now) { + Some(Due::End(reason)) => end(leg, s, reason, now), + Some(Due::Roster) => roster(leg, s, now), + Some(Due::Forget) | None => {} + } +} + +fn send(leg: &mut Leg, frame: &str, now: i64) { + if let Some(conn) = leg.ws + && websocket::send(conn, frame).is_err() + { + websocket::close(conn); + leg.ws = None; + leg.trouter.lost(now); + leg.captions.down(now); + } +} + +/// The registration with the first connection's `surl` (spec 5.3), with the IC3 token. +fn register(leg: &mut Leg, s: &mut Session, now: i64) { + let Some(request) = leg.trouter.registration() else { return }; + let status = api::send(s, Service::Chat, &request, false).map(|r| r.status); + host::log(2, &format!("live captions: registration HTTP {}", status.as_ref().map_or_else(|_| "failed".to_string(), u16::to_string))); + let ok = matches!(status, Ok(200..=299)); + leg.registered |= ok; + leg.trouter.registered(ok, now); +} + +/// One Trouter frame (spec 5.3). +fn frame(leg: &mut Leg, s: &mut Session, text: &str, now: i64) { + if !leg.captions.active() { + return; + } + let (reply, incoming) = match leg.trouter.handle(text, now) { + Ok(handled) => handled, + Err(_) => return end(leg, s, Reason::Protocol, now), + }; + if let Some(reply) = reply { + send(leg, &reply, now); + } + match incoming { + Incoming::Open => match api::chat_token(s) { + Ok(token) => { + let frame = leg.trouter.authenticate(&token); + send(leg, frame.expose(), now); + } + Err(_) => end(leg, s, Reason::Protocol, now), + }, + Incoming::Connected { same_surl: false, .. } => end(leg, s, Reason::Protocol, now), + // Up again, also when the first socket fell before its `trouter.connected` (Codex review of + // 263a526). + Incoming::Connected { first, .. } => { + leg.captions.up(); + if first { + register(leg, s, now); + call(leg, s, now); + } else { + roster(leg, s, now); + } + } + Incoming::Callback(callback) => match captions::callback(&callback.url, &callback.body, &leg.callbacks) { + Ok(Call::Acceptance { answer, acknowledgement }) => accept(leg, s, &answer, acknowledgement.as_deref(), now), + Ok(Call::Roster) => roster(leg, s, now), + Ok(Call::Ended { meeting, code, sub_code }) => { + let what = if meeting { "meeting" } else { "call" }; + let shown = |c: Option| c.map_or_else(|| "none".to_string(), |c| c.to_string()); + host::log(1, &format!("live captions: Teams ended the {what} (code {}, sub-code {})", shown(code), shown(sub_code))); + end(leg, s, if meeting { Reason::MeetingEnded } else { Reason::Refused }, now); + } + Ok(Call::Other) => {} + Err(_) => end(leg, s, Reason::Protocol, now), + }, + Incoming::Lost => { + leg.captions.pause(now); + roster(leg, s, now); + } + Incoming::Closing => { + if let Some(conn) = leg.ws.take() { + websocket::close(conn); + } + leg.trouter.lost(now); + leg.captions.down(now); + } + Incoming::Nothing => {} + } +} + +/// The join (spec 4.1, docs "Live results" 18): the media connection's offer in MS-SDP, the +/// session record, then `POST `. +fn call(leg: &mut Leg, s: &mut Session, now: i64) { + let (conn, offer) = match rtc::open(LABEL) { + Ok(opened) => opened, + Err(HttpError::Blocked(why)) if why.starts_with("at most") => return end(leg, s, Reason::ConnectionLimit, now), + Err(_) => return end(leg, s, Reason::MediaFailed, now), + }; + leg.rtc = Some(conn); + let offer = match sdp::to_ms(&offer, u32::try_from(api::random() >> 33).unwrap_or(1) | 1) { + Ok(offer) => offer, + Err(_) => return end(leg, s, Reason::Protocol, now), + }; + let Some(surl) = leg.trouter.surl().map(str::to_string) else { return end(leg, s, Reason::Protocol, now) }; + // No join without its record: a crash could not leave it then (Codex review of 7385fdd). + let mut all = records(&leg.account); + all.push(Record { controller: leg.live.controller.clone(), endpoint: leg.endpoint.clone(), participant: leg.participant.clone(), at_ms: now }); + if !store_records(&leg.account, &all) { + return end(leg, s, Reason::Protocol, now); + } + let mri = s.me(); + let links = Links { surl: &surl, session: &leg.callbacks, tag: leg.tag }; + let body = captions::join(&me(s, leg, &mri), &links, &leg.live, &leg.captions.conversation, &offer, &leg.media_leg); + leg.joined = true; + let controller = leg.live.controller.clone(); + let joined = service(s, leg, "POST", &controller, Some(&body), true); + host::log(2, &format!("live captions: join HTTP {}", joined.as_ref().map_or_else(|_| "failed".to_string(), |r| r.status.to_string()))); + match joined { + Ok(r) if (200..=299).contains(&r.status) => match captions::conversation(&r.body, &mri, &leg.endpoint) { + Ok((link, roster)) => { + leg.leave = Some(link); + if let Some(roster) = roster + && let Some(reason) = leg.captions.roster(&roster, now) + { + end(leg, s, reason, now); + } + } + Err(_) => end(leg, s, Reason::Protocol, now), + }, + // Refused: the service did not take it. + Ok(_) => { + leg.joined = false; + end(leg, s, Reason::Refused, now); + } + Err(_) => end(leg, s, Reason::Protocol, now), + } +} + +/// The SDP answer of `/call/acceptance/`: to the host in standard SDP, then the acknowledgement. +fn accept(leg: &mut Leg, s: &mut Session, answer: &str, acknowledgement: Option<&str>, now: i64) { + let Some(conn) = leg.rtc else { return }; + let plain = match sdp::from_ms(answer) { + Ok(plain) => plain, + Err(e) => { + host::log(1, &format!("live captions: the SDP answer was not taken: {e:?}")); + return end(leg, s, Reason::Protocol, now); + } + }; + match rtc::accept(conn, &plain) { + Ok(()) => { + host::log(2, "live captions: the SDP answer was accepted"); + leg.captions.answered(); + } + Err(HttpError::Blocked(why)) if why.starts_with("proxy:") => return end(leg, s, Reason::Proxy, now), + Err(e) => { + host::log(1, &format!("live captions: the media connection refused the answer: {}", describe(&e))); + return end(leg, s, Reason::MediaFailed, now); + } + } + if let Some(url) = acknowledgement + && service(s, leg, "POST", url, Some(&captions::acknowledgement()), false).is_err() + { + host::log(1, "the call acceptance was not acknowledged"); + } +} + +/// Reads the roster (`GET `): whether the user is still in the meeting and captions +/// are on (spec 4.1, 5.3). +fn roster(leg: &mut Leg, s: &mut Session, now: i64) { + let controller = leg.live.controller.clone(); + let read = service(s, leg, "GET", &controller, None, false).ok().filter(|r| (200..=299).contains(&r.status)); + let mri = s.me(); + match read.map(|r| captions::conversation(&r.body, &mri, &leg.endpoint)) { + Some(Ok((link, roster))) => { + leg.leave.get_or_insert(link); + if let Some(roster) = &roster { + host::log(2, &format!("live captions: roster: the user elsewhere {}, captions {}", roster.user_elsewhere, roster.captions)); + } + if let Some(roster) = roster + && let Some(reason) = leg.captions.roster(&roster, now) + { + return end(leg, s, reason, now); + } + } + Some(Err(_)) | None => host::log(1, "the meeting's roster could not be read"), + } + leg.captions.roster_polled(now); +} diff --git a/modules/teams/src/manifest.rs b/modules/teams/src/manifest.rs index e4df497..809827b 100644 --- a/modules/teams/src/manifest.rs +++ b/modules/teams/src/manifest.rs @@ -16,6 +16,8 @@ pub struct Manifest { pub sign_in_hosts: Vec, pub wam: Option, pub extra_tools: Vec, + /// `media.peers`: the address ranges of media peers. + pub media: Option>, } /// Mirrors `wam-identity` in `wit/module.wit`. @@ -52,8 +54,14 @@ const HOSTS: &[&str] = &[ "substrate.office.com", // 7: files on the organisation's SharePoint and OneDrive (spec 9.3). "*.sharepoint.com", + // 10.7: the call store and the conversation service of live captions. + "api.flightproxy.teams.microsoft.com", ]; +/// Teams media, ID 11 of Microsoft's published Microsoft 365 address list (docs 10.7): the only +/// peers of live captions' data channel (spec 2026-10-29-live-captions, 5.2). +const MEDIA_PEERS: &[&str] = &["52.112.0.0/14", "52.122.0.0/15", "2603:1063::/38"]; + /// Token audiences the module asks WAM for (docs/protocols/teams.md 1.4): authsvc and middle /// tier, chat service, search, MCPS. SharePoint's audience is the organization's own origin, known /// only after sign-in, so it is a pattern of one label (spec 2026-10-28-meetings-transcripts, 5.2). @@ -69,11 +77,12 @@ pub fn manifest() -> Manifest { sign_in_hosts: strings(&["login.microsoftonline.com"]), wam: Some(WamIdentity { client_id: CLIENT_ID.to_string(), scopes: strings(SCOPES) }), extra_tools: extra_tools(), + media: Some(strings(MEDIA_PEERS)), } } -/// The module's own tools, all reads (spec 2026-10-28-meetings-transcripts, 4); the core lists -/// them as `teams_`. +/// The module's own tools (spec 2026-10-28-meetings-transcripts, 4, and 2026-10-29-live-captions, +/// 4); the core lists them as `teams_`. Only `join_captions` writes. fn extra_tools() -> Vec { let time = |what: &str| serde_json::json!({"type": "string", "description": what}); let list_meetings = serde_json::json!({ @@ -100,6 +109,17 @@ fn extra_tools() -> Vec { "required": ["conversation"], "additionalProperties": false, }); + let read_captions = serde_json::json!({ + "type": "object", + "properties": { + "conversation": text("The meeting chat, 19:meeting_..., the session was joined with."), + "session": text("session of the previous result; another one means the session changed."), + "cursor": {"type": "integer", "description": "next_cursor of the previous result; needs session."}, + "max_bytes": {"type": "integer", "description": "Size of the cues as JSON, 4096-262144; 65536 when left out."}, + }, + "required": ["conversation"], + "additionalProperties": false, + }); vec![ ToolDecl { name: "list_meetings".to_string(), @@ -113,15 +133,58 @@ fn extra_tools() -> Vec { name: "get_transcript".to_string(), description: "The Teams transcript of a meeting, a page of cues (start, end, speaker, text) at a time; for the next page pass \ next_cursor as cursor with transcript and version. Any meeting the user is in, of the user's own \ - organization. Reading never marks \ - anything as read." + organization. Reading never marks anything as read. What people wrote in the meeting chat is part \ + of the meeting too: read_messages on the same conversation reads it, with since a little before \ + this occurrence's start from list_meetings, and on with before (the oldest message returned) \ + while a read returns limit messages. A recurring meeting keeps one chat for all its occurrences, \ + so keep the messages up to around this occurrence's end." .to_string(), input_schema: get_transcript.to_string(), writes: false, }, + ToolDecl { + name: "join_captions".to_string(), + description: "Join the meeting for its live captions. open_mess joins the meeting as a second device of your \ + account, without audio or video, only to read the captions someone turned on in Teams, and \ + only while you are in the meeting in Teams yourself; Teams counts a second device of your \ + account in the meeting meanwhile. Done means joining started: read_captions tells how it goes." + .to_string(), + input_schema: meeting(&text).to_string(), + writes: true, + }, + ToolDecl { + name: "read_captions".to_string(), + description: "The live captions of the meeting open_mess joined with join_captions: the session's state, \ + the final cues (at, speaker, text) from cursor, how many were missed, and why the session \ + ended; pass session and next_cursor of the last result to go on. What people write in the \ + meeting chat is part of the meeting too: read_messages on the same conversation reads it, with \ + since this meeting's start from list_meetings or the time you joined (a recurring meeting keeps \ + one chat for all its occurrences); while a read returns limit messages, go on with before (the \ + oldest message returned) and the same since. An edited message keeps its time, so read from \ + that start again for the final text." + .to_string(), + input_schema: read_captions.to_string(), + writes: false, + }, + ToolDecl { + name: "leave_captions".to_string(), + description: "Leaves the meeting open_mess joined for its live captions; only open_mess's device leaves.".to_string(), + input_schema: meeting(&text).to_string(), + writes: false, + }, ] } +/// The schema of a tool that takes only the meeting chat. +fn meeting(text: &impl Fn(&str) -> serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": {"conversation": text("The meeting chat, 19:meeting_..., from list_meetings or list_conversations.")}, + "required": ["conversation"], + "additionalProperties": false, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -135,9 +198,9 @@ mod tests { } #[test] - fn extra_tools_are_reads_with_object_schemas() { + fn extra_tools_have_object_schemas_and_only_the_join_writes() { for tool in manifest().extra_tools { - assert!(!tool.writes, "{}", tool.name); + assert_eq!(tool.writes, tool.name == "join_captions", "{}", tool.name); let schema: serde_json::Value = serde_json::from_str(&tool.input_schema).unwrap(); assert_eq!( (&schema["type"], &schema["additionalProperties"]), diff --git a/modules/teams/src/meetings.rs b/modules/teams/src/meetings.rs index 33bf8ab..c6f1983 100644 --- a/modules/teams/src/meetings.rs +++ b/modules/teams/src/meetings.rs @@ -457,7 +457,7 @@ pub fn decode_transcript_list(body: &[u8], transcript_id: Option<&str>) -> Resul } /// `^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8}){0,4}$` (spec 4.2 step 6). -fn language_tag(tag: &str) -> bool { +pub(crate) fn language_tag(tag: &str) -> bool { let mut parts = tag.split('-'); let first = parts.next().unwrap_or_default(); let rest: Vec<&str> = parts.collect(); @@ -574,7 +574,7 @@ fn entities(text: &str) -> String { } /// `text` cut to `max` characters, the last one the cut mark. -fn cut(text: &str, max: usize) -> String { +pub(crate) fn cut(text: &str, max: usize) -> String { if text.chars().count() <= max { return text.to_string(); } diff --git a/modules/teams/src/sdp.rs b/modules/teams/src/sdp.rs new file mode 100644 index 0000000..881b548 --- /dev/null +++ b/modules/teams/src/sdp.rs @@ -0,0 +1,338 @@ +//! The host's offer in Teams' SDP dialect (MS-SDP) and Teams' answer back to standard SDP, for a +//! live-captions session's data channel (spec 2026-10-29-live-captions, 5.3; docs/protocols/teams.md +//! 10.7 and "Live results" 16 and 18). Strict: one data section only, known lines only, bounded +//! sizes; anything else is an error, never a guess. + +use crate::{ProtoError, incompatible}; + +/// Bytes of an SDP the module converts, either way. +const MAX_SDP: usize = 16 * 1024; +const MAX_LINE: usize = 512; +const MAX_LINES: usize = 128; +/// The SCTP port of the data channel; Teams' answer names none (docs, "Live results" 16). +const SCTP_PORT: &str = "a=sctp-port:5000"; + +/// The lines of an SDP within the limits, `\r` dropped. +fn lines(sdp: &str) -> Result, ProtoError> { + if sdp.len() > MAX_SDP { + return Err(incompatible("sdp: too large")); + } + let lines: Vec<&str> = sdp.lines().map(|l| l.trim_end_matches('\r')).filter(|l| !l.is_empty()).collect(); + if lines.len() > MAX_LINES || lines.iter().any(|l| l.len() > MAX_LINE) { + return Err(incompatible("sdp: too many or too long lines")); + } + Ok(lines) +} + +/// The attribute name of an `a=` line, for errors: never its value, which may hold addresses. +fn name(line: &str) -> String { + line.split([':', ' ']).next().unwrap_or("").chars().take(32).collect() +} + +fn unknown(line: &str) -> ProtoError { + incompatible(&format!("sdp: unexpected line {}", name(line))) +} + +/// Splits at the `m=` line: the session part and the one media section. +fn one_section<'a>(lines: &[&'a str]) -> Result<(Vec<&'a str>, &'a str, Vec<&'a str>), ProtoError> { + let mut sections = lines.iter().enumerate().filter(|(_, l)| l.starts_with("m=")); + let (at, media) = sections.next().ok_or_else(|| incompatible("sdp: no media section"))?; + if sections.next().is_some() { + return Err(incompatible("sdp: more than the data section")); + } + Ok((lines.iter().take(at).copied().collect(), media, lines.iter().skip(at + 1).copied().collect())) +} + +/// The web client's MS-SDP of the host's offer (`toMsSdp` with `Kz.modify` in +/// `calling-pluginless`), as the probe sent it in the data-only join that gave captions (docs, +/// "Live results" 18): the section becomes `m=x-data RTP/SAVP 127 126` with the x-data +/// lines, a label and the session's fingerprint; the default host candidate (the first IPv4 UDP +/// host candidate of component 1) is the only one, and its address and port are on the `m=` and +/// `c=` lines. `ssrc` is the section's `a=x-ssrc-range`. +pub fn to_ms(offer: &str, ssrc: u32) -> Result { + let lines = lines(offer)?; + let (session, media, section) = one_section(&lines)?; + let fields: Vec<&str> = media.split(' ').collect(); + if fields.len() != 4 || fields.first() != Some(&"m=application") || fields.get(2) != Some(&"UDP/DTLS/SCTP") { + return Err(incompatible("sdp: the offer's section is not a data channel")); + } + let candidate = section + .iter() + .filter_map(|l| l.strip_prefix("a=candidate:")) + .map(|c| c.split(' ').collect::>()) + .find(|f| { + f.get(1) == Some(&"1") + && f.get(2).is_some_and(|t| t.eq_ignore_ascii_case("udp")) + && f.get(4).is_some_and(|ip| ip.parse::().is_ok()) + && f.get(6) == Some(&"typ") + && f.get(7) == Some(&"host") + }) + .ok_or_else(|| incompatible("sdp: the offer has no IPv4 host candidate"))?; + let (ip, port) = (candidate.get(4).copied().unwrap_or_default(), candidate.get(5).copied().unwrap_or_default()); + let mut fingerprints = Vec::new(); + let mut out = Vec::new(); + for line in &session { + match *line { + l if l.starts_with("a=fingerprint:") => fingerprints.push(l.to_string()), + l if l.starts_with("a=msid-semantic") || l.starts_with("a=extmap-allow-mixed") || l.starts_with("a=ice-options:") => {} + l if ["v=", "o=", "t=", "a=group:BUNDLE ", "a=ice-ufrag:", "a=ice-pwd:", "a=setup:", "a=ice-lite"].iter().any(|p| l.starts_with(p)) => { + out.push(l.to_string()); + } + "s=-" => { + out.push("s=-".to_string()); + out.push("b=CT:4000".to_string()); + } + l if l.starts_with("c=") || l.starts_with("b=") => {} + l => return Err(unknown(l)), + } + } + out.push("a=msid-semantic: WMS *".to_string()); + out.extend([ + format!("m=x-data {port} RTP/SAVP 127 126"), + format!("c=IN IP4 {ip}"), + "a=ice-options:trickle".to_string(), + format!("a=candidate:{}", candidate.iter().take(8).copied().collect::>().join(" ")), + "a=rtpmap:127 x-data/90000".to_string(), + "a=rtpmap:126 rtx/90000".to_string(), + "a=fmtp:126 apt=127".to_string(), + "a=x-data-protocol:sctp".to_string(), + format!("a=x-ssrc-range:{ssrc}-{ssrc}"), + "a=rtcp-mux".to_string(), + ]); + let mut has_fingerprint = false; + for line in §ion { + match *line { + l if l.starts_with("a=candidate:") || l == "a=end-of-candidates" || l.starts_with("a=ice-options:") || l.starts_with("c=") => {} + l if l.starts_with("a=max-message-size:") => out.push("a=max-message-size:262144".to_string()), + l if l.starts_with("a=fingerprint:") => { + has_fingerprint = true; + out.push(l.to_string()); + } + l if ["a=setup:", "a=mid:", "a=sctp-port:", "a=ice-ufrag:", "a=ice-pwd:", "a=sendrecv", "a=rtcp-mux"] + .iter() + .any(|p| l.starts_with(p)) => + { + if l != "a=rtcp-mux" { + out.push(l.to_string()); + } + } + l => return Err(unknown(l)), + } + } + out.push("a=label:data".to_string()); + if !has_fingerprint { + out.extend(fingerprints); + } + Ok(out.join("\r\n") + "\r\n") +} + +/// Teams' answer as standard SDP for the host (the client's `Xz`): its one `m=x-data +/// RTP/SAVP` section becomes `m=application UDP/DTLS/SCTP webrtc-datachannel`, its rtp, +/// fmtp, rtcp, x- and label lines go, and it gets an `a=sctp-port`. A section with port 0 (the +/// service refused the data channel), an audio or video section, or an unknown line is an error. +pub fn from_ms(answer: &str) -> Result { + let lines = lines(answer)?; + // Every unknown line at once, so one answer names them all. + let mut unknown_lines: Vec<&str> = Vec::new(); + let (session, media, section) = one_section(&lines)?; + let fields: Vec<&str> = media.split(' ').collect(); + let port = match (fields.first(), fields.get(1), fields.get(2)) { + (Some(&"m=x-data"), Some(port), Some(&"RTP/SAVP")) => *port, + _ => return Err(incompatible("sdp: the answer's section is not the data channel")), + }; + if !port.parse::().is_ok_and(|p| p != 0) { + return Err(incompatible("sdp: the answer refused the data channel")); + } + let mut out = Vec::new(); + for line in &session { + match *line { + l if [ + "v=", + "o=", + "s=", + "t=", + "c=", + "b=", + "a=group:BUNDLE ", + "a=ice-ufrag:", + "a=ice-pwd:", + "a=fingerprint:", + "a=setup:", + "a=ice-lite", + ] + .iter() + .any(|p| l.starts_with(p)) => + { + out.push(l.to_string()); + } + l if l.starts_with("a=msid-semantic") || l.starts_with("a=ice-options:") || l.starts_with("a=x-") => {} + l => unknown_lines.push(l), + } + } + out.push(format!("m=application {port} UDP/DTLS/SCTP webrtc-datachannel")); + let mut has_port = false; + for line in §ion { + match *line { + // RTP's lines (the data section's payload types and its stream ids: `a=ssrc` and + // `a=ssrc-group` came live, "Live results" 19), which a data channel has no use for. + l if [ + "a=rtpmap:", + "a=fmtp:", + "a=rtcp:", + "a=rtcp-fb:", + "a=x-", + "a=label:", + "a=crypto:", + "a=ice-options:", + "a=ssrc", + "a=msid", + "a=rtcp-rsize", + ] + .iter() + .any(|p| l.starts_with(p)) => {} + l if [ + "c=", + "b=", + "a=candidate:", + "a=end-of-candidates", + "a=ice-ufrag:", + "a=ice-pwd:", + "a=fingerprint:", + "a=setup:", + "a=mid:", + "a=rtcp-mux", + "a=sendrecv", + "a=sctp-port:", + "a=max-message-size:", + ] + .iter() + .any(|p| l.starts_with(p)) => + { + has_port |= l.starts_with("a=sctp-port:"); + out.push(l.to_string()); + } + l => unknown_lines.push(l), + } + } + if !unknown_lines.is_empty() { + let names: Vec = unknown_lines.iter().map(|l| name(l)).collect(); + return Err(incompatible(&format!("sdp: unexpected lines {}", names.join(" ")))); + } + if !has_port { + out.push(SCTP_PORT.to_string()); + } + Ok(out.join("\r\n") + "\r\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A data-channel offer as webrtc-rs makes it, with documentation addresses and invented keys. + const OFFER: &str = "v=0\r\no=- 1234 1 IN IP4 0.0.0.0\r\ns=-\r\nt=0 0\r\n\ + a=fingerprint:sha-256 AA:BB:CC\r\na=group:BUNDLE 0\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS\r\n\ + m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\na=setup:actpass\r\na=mid:0\r\n\ + a=sendrecv\r\na=sctp-port:5000\r\na=max-message-size:1073741823\r\na=ice-ufrag:ufrag\r\na=ice-pwd:password\r\n\ + a=candidate:1 1 udp 2130706431 2001:db8::1 50001 typ host\r\n\ + a=candidate:2 1 udp 2130706431 192.0.2.10 50000 typ host generation 0 network-id 1\r\n\ + a=candidate:3 1 udp 2130706431 192.0.2.11 50002 typ host\r\na=end-of-candidates\r\n"; + + #[test] + fn the_offer_becomes_the_web_clients_data_section() { + let ms = to_ms(OFFER, 7).unwrap(); + let expected = [ + "v=0", + "o=- 1234 1 IN IP4 0.0.0.0", + "s=-", + "b=CT:4000", + "t=0 0", + "a=group:BUNDLE 0", + "a=msid-semantic: WMS *", + "m=x-data 50000 RTP/SAVP 127 126", + "c=IN IP4 192.0.2.10", + "a=ice-options:trickle", + "a=candidate:2 1 udp 2130706431 192.0.2.10 50000 typ host", + "a=rtpmap:127 x-data/90000", + "a=rtpmap:126 rtx/90000", + "a=fmtp:126 apt=127", + "a=x-data-protocol:sctp", + "a=x-ssrc-range:7-7", + "a=rtcp-mux", + "a=setup:actpass", + "a=mid:0", + "a=sendrecv", + "a=sctp-port:5000", + "a=max-message-size:262144", + "a=ice-ufrag:ufrag", + "a=ice-pwd:password", + "a=label:data", + "a=fingerprint:sha-256 AA:BB:CC", + ]; + assert_eq!(ms, expected.join("\r\n") + "\r\n"); + } + + #[test] + fn an_offer_with_more_or_other_is_refused() { + let audio = format!("{OFFER}m=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=mid:1\r\n"); + assert!(to_ms(&audio, 1).is_err()); + assert!(to_ms(&OFFER.replace("m=application 9 UDP/DTLS/SCTP", "m=audio 9 UDP/TLS/RTP/SAVPF"), 1).is_err()); + assert!(to_ms(&OFFER.replace("a=sendrecv", "a=ssrc:1 cname:x"), 1).is_err()); + let no_v4 = OFFER.replace("192.0.2.10", "2001:db8::2").replace("192.0.2.11", "2001:db8::3"); + assert!(to_ms(&no_v4, 1).is_err()); + assert!(to_ms(&format!("{OFFER}a=x:{}\r\n", "y".repeat(MAX_LINE)), 1).is_err()); + assert!(to_ms("", 1).is_err()); + } + + /// Teams' answer to a data-only offer (docs, "Live results" 18), with documentation-style + /// addresses in its media range and invented keys. + const ANSWER: &str = "v=0\r\no=- 0 0 IN IP4 52.112.0.10\r\ns=session\r\nc=IN IP4 52.112.0.10\r\nb=CT:10000000\r\nt=0 0\r\n\ + a=x-mediabw:data-video send=4000;recv=4000\r\nm=x-data 3480 RTP/SAVP 127 126\r\nc=IN IP4 52.112.0.10\r\n\ + a=rtpmap:127 x-data/90000\r\na=rtpmap:126 rtx/90000\r\na=fmtp:126 apt=127\r\na=rtcp:3480\r\n\ + a=ice-ufrag:ufrag\r\na=ice-pwd:password\r\na=fingerprint:sha-256 DD:EE:FF\r\na=setup:passive\r\n\ + a=candidate:1 1 UDP 2130706431 52.112.0.10 3480 typ relay raddr 52.112.0.10 rport 3480 MTURNID 1\r\n\ + a=candidate:2 1 tcp-pass 174455807 52.112.0.11 3480 typ relay raddr 52.112.0.11 rport 3480\r\n\ + a=x-source-streamid:1\r\na=x-ssrc-range:100-100\r\na=x-data-protocol:sctp\r\na=label:data\r\na=rtcp-mux\r\n\ + a=mid:0\r\na=sendrecv\r\n"; + + #[test] + fn the_answer_becomes_a_standard_data_section() { + let plain = from_ms(ANSWER).unwrap(); + let expected = [ + "v=0", + "o=- 0 0 IN IP4 52.112.0.10", + "s=session", + "c=IN IP4 52.112.0.10", + "b=CT:10000000", + "t=0 0", + "m=application 3480 UDP/DTLS/SCTP webrtc-datachannel", + "c=IN IP4 52.112.0.10", + "a=ice-ufrag:ufrag", + "a=ice-pwd:password", + "a=fingerprint:sha-256 DD:EE:FF", + "a=setup:passive", + "a=candidate:1 1 UDP 2130706431 52.112.0.10 3480 typ relay raddr 52.112.0.10 rport 3480 MTURNID 1", + "a=candidate:2 1 tcp-pass 174455807 52.112.0.11 3480 typ relay raddr 52.112.0.11 rport 3480", + "a=rtcp-mux", + "a=mid:0", + "a=sendrecv", + "a=sctp-port:5000", + ]; + assert_eq!(plain, expected.join("\r\n") + "\r\n"); + } + + #[test] + fn an_answer_with_audio_a_refusal_or_unknown_lines_is_an_error() { + let audio = ANSWER.replace("m=x-data 3480", "m=audio 3478 RTP/SAVP 111\r\na=recvonly\r\nm=x-data 3480"); + assert!(from_ms(&audio).is_err()); + assert!(from_ms(&ANSWER.replace("m=x-data 3480", "m=x-data 0")).is_err()); + assert!(from_ms(&ANSWER.replace("m=x-data 3480", "m=audio 3480")).is_err()); + // RTP's stream lines go; other unknown lines are named, all of them. + let ssrc = ANSWER.replace("a=sendrecv", "a=sendrecv\r\na=ssrc:1 cname:x\r\na=ssrc-group:FID 1 2\r\na=msid:x y"); + assert_eq!(from_ms(&ssrc), from_ms(ANSWER)); + let odd = ANSWER.replace("a=sendrecv", "a=foo:1\r\na=bar"); + assert_eq!(from_ms(&odd), Err(incompatible("sdp: unexpected lines a=foo a=bar"))); + assert!(from_ms(&"a=x-y\r\n".repeat(MAX_LINES + 1)).is_err()); + assert!(from_ms(&format!("{ANSWER}{}", "a=x-y\r\n".repeat(MAX_SDP / 6))).is_err()); + assert!(from_ms("v=0\r\n").is_err()); + } +} diff --git a/modules/teams/src/session.rs b/modules/teams/src/session.rs index b2d6aa8..4d042d0 100644 --- a/modules/teams/src/session.rs +++ b/modules/teams/src/session.rs @@ -84,6 +84,8 @@ pub struct Session { pub upn: Option, pub tenant: Option, pub directory: ServiceDirectory, + /// authsvc's `region` and `partition`. + pub region: Option<(String, String)>, /// Scope, token and when it expires. SharePoint's scope is the organization's origin, so /// scopes are not all constants. tokens: Vec<(String, SecretString, Option)>, @@ -104,6 +106,7 @@ impl Session { upn: claims.upn, tenant: claims.tenant, directory, + region: None, tokens: Vec::new(), skype: None, sharepoint_tenant: None, diff --git a/modules/teams/src/tools.rs b/modules/teams/src/tools.rs index adb00b0..b61b901 100644 --- a/modules/teams/src/tools.rs +++ b/modules/teams/src/tools.rs @@ -44,6 +44,9 @@ pub fn call(account: &str, tool: &str, args_json: &str) -> Result react, "list_meetings" => list_meetings, "get_transcript" => get_transcript, + "join_captions" => crate::live::join, + "read_captions" => crate::live::read, + "leave_captions" => crate::live::leave, _ => return Err(ModuleError::NotSupported(tool.to_string())), }; api::with_session(account, |session| run(session, &args)).map(|result| result.to_string()) @@ -113,8 +116,16 @@ fn conversations(session: &mut Session, start_ms: i64, partial: bool, mut keep: /// Walks a conversation's text messages, newest first, while `keep` returns true, within /// `MAX_PAGES` and the call's time. -/// With `end_ms`, the walk starts at messages before then. -fn messages(session: &mut Session, conversation: &str, end_ms: Option, mut keep: impl FnMut(Message) -> bool) -> Result<(), ModuleError> { +/// With `end_ms`, the walk starts at messages before then. With `partial`, a walk the pages or the +/// time cut short ends with what it saw; without, that is an error, so a short list always means +/// there is no more (`read_messages`; spec 2026-10-29-live-captions, 4.5). +fn messages( + session: &mut Session, + conversation: &str, + end_ms: Option, + partial: bool, + mut keep: impl FnMut(Message) -> bool, +) -> Result<(), ModuleError> { let mut link: Option = None; for _ in 0..MAX_PAGES { let request = chat::read_messages(&session.directory, conversation, link.as_deref(), PAGE_SIZE, end_ms).map_err(api::proto)?; @@ -127,10 +138,11 @@ fn messages(session: &mut Session, conversation: &str, end_ms: Option, mut } match page.backward_link { Some(next) if !empty && api::has_time() => link = Some(next), + Some(_) if !empty && !partial => return Err(ModuleError::Platform("Teams answers too slowly; try again later".to_string())), _ => return Ok(()), } } - Ok(()) + if partial { Ok(()) } else { Err(ModuleError::Platform("could not read that far back in one call".to_string())) } } fn message_item(m: &Message, me: &str) -> Value { @@ -254,7 +266,7 @@ fn mentions(session: &mut Session, args: &Value) -> Result { if !api::has_time() { break; } - messages(session, &conversation, None, |m| { + messages(session, &conversation, None, true, |m| { if m.sent_ms <= since { return false; } @@ -284,7 +296,7 @@ fn read_messages(session: &mut Session, args: &Value) -> Result= b) { return true; } @@ -333,7 +345,7 @@ fn writing_context(session: &mut Session, args: &Value) -> Result, + registrar: Option, + /// From the latest `trouter.connected`, for the next connection. + connectparams: Option>, + reconnect_url: Option, + /// Since when the connection, or the renewal, has been failing. + lost_since: Option, + renewal_failing_since: Option, + /// Failed connections in a row, for the backoff. + failures: u32, + ping_at: Option, + pings: u64, + renew_at: Option, +} + +impl Trouter { + /// The state of a first socket, about to be opened at [`Trouter::url`]. + pub fn new(endpoint: &str, client: &str) -> Trouter { + Trouter { + endpoint: endpoint.to_string(), + client: client.to_string(), + attempts: 0, + link: Link::Opening, + surl: None, + registrar: None, + connectparams: None, + reconnect_url: None, + lost_since: None, + renewal_failing_since: None, + failures: 0, + ping_at: None, + pings: 0, + renew_at: None, + } + } + + /// The callback base of the first connection. + pub fn surl(&self) -> Option<&str> { + self.surl.as_deref() + } + + /// The URL of the next socket: the reconnect URL once known, else the default, with the + /// probe's v4c query where the URL lacks a key ("Live results" 13). `correlation` is a new + /// random id. + pub fn url(&mut self, correlation: &str) -> String { + self.attempts = self.attempts.saturating_add(1); + let base = self.reconnect_url.as_deref().unwrap_or(DEFAULT_URL); + let present: Vec = base + .split_once('?') + .map(|(_, query)| form_urlencoded::parse(query.as_bytes()).map(|(key, _)| key.into_owned()).collect()) + .unwrap_or_default(); + let tc = json!({"cv": TROUTER_VERSION, "ua": "TeamsCDL", "hr": "", "v": CLIENT_BUILD}).to_string(); + let con_num = format!("{}_{}", self.client, self.attempts); + let mut query = form_urlencoded::Serializer::new(String::new()); + for (key, value) in + [("tc", tc.as_str()), ("timeout", "40"), ("epid", self.endpoint.as_str()), ("cor_id", correlation), ("con_num", con_num.as_str())] + { + if !present.iter().any(|p| p == key) { + query.append_pair(key, value); + } + } + let query = query.finish(); + match (query.is_empty(), base.contains('?')) { + (true, _) => base.to_string(), + (false, true) => format!("{base}&{query}"), + (false, false) => format!("{base}?{query}"), + } + } + + /// `user.authenticate` with the IC3 token, and on a reconnection the `connectparams` of the + /// latest connection (docs 10.7). + pub fn authenticate(&self, token: &str) -> SecretString { + let headers = json!({"X-Ms-Test-User": "False", "Authorization": format!("Bearer {token}"), "X-MS-Migration": "True"}); + let mut args = Map::new(); + args.insert("headers".to_string(), headers); + if let Some(params) = &self.connectparams { + args.insert("connectparams".to_string(), Value::Object(params.clone())); + } + SecretString::new(format!("5:::{}", json!({"name": "user.authenticate", "args": [args]}))) + } + + /// One frame from the server: the frame to send back, if any, and what it means. A + /// `trouter.connected` the module cannot use is an error; anything else unknown is nothing. + pub fn handle(&mut self, text: &str, now: i64) -> Result<(Option, Incoming), ProtoError> { + // `type:id[+]:endpoint[:data]` (docs 10.7). + let mut parts = text.splitn(4, ':'); + let (kind, id) = (parts.next().unwrap_or_default(), parts.next().unwrap_or_default()); + let data = parts.nth(1).unwrap_or_default(); + Ok(match kind { + "0" => (None, Incoming::Closing), + "1" => (None, Incoming::Open), + "2" => (Some("2::".to_string()), Incoming::Nothing), + "3" => request(data), + "5" => { + let ack = id.strip_suffix('+').filter(|n| (1..=20).contains(&n.len()) && n.bytes().all(|b| b.is_ascii_digit())); + (ack.map(|n| format!("6:::{n}")), self.event(data, now)?) + } + _ => (None, Incoming::Nothing), + }) + } + + fn event(&mut self, data: &str, now: i64) -> Result { + let Ok(event) = serde_json::from_str::(data) else { + return Ok(Incoming::Nothing); + }; + match event.get("name").and_then(Value::as_str) { + Some("trouter.connected") => self.connected(event.get("args").and_then(|args| args.get(0)), now), + Some("trouter.message_loss") => Ok(Incoming::Lost), + _ => Ok(Incoming::Nothing), + } + } + + fn connected(&mut self, args: Option<&Value>, now: i64) -> Result { + let args = args.and_then(Value::as_object).ok_or_else(|| incompatible("trouter.connected.args"))?; + let surl = args + .get("surl") + .and_then(Value::as_str) + .filter(|surl| Url::parse(surl).is_ok_and(|u| u.scheme() == "https" && u.host_str().is_some())) + .ok_or_else(|| incompatible("trouter.connected.surl"))?; + let mut params = + args.get("connectparams").and_then(Value::as_object).cloned().ok_or_else(|| incompatible("trouter.connected.connectparams"))?; + params.remove("reconnectUrl"); + params.remove("serviceUrl"); + self.connectparams = Some(params); + // Only a URL on the manifest's Trouter hosts; the default URL reconnects as well + // ("Live results" 18). + self.reconnect_url = args + .get("reconnectUrl") + .and_then(Value::as_str) + .filter(|url| on_host(url, "wss", |host| host.ends_with(".trouter.teams.microsoft.com"))) + .map(str::to_string); + let first = self.surl.is_none(); + if first { + // The token goes only to Teams' own registrar host. + self.registrar = args + .get("registrarUrl") + .and_then(Value::as_str) + .filter(|url| on_host(url, "https", |host| host == "teams.microsoft.com")) + .map(str::to_string); + } + let same_surl = self.surl.get_or_insert_with(|| surl.to_string()) == surl; + self.link = Link::Up; + self.failures = 0; + self.lost_since = None; + self.ping_at = Some(now.saturating_add(PING_MS)); + Ok(Incoming::Connected { first, same_surl }) + } + + /// The registration of open_mess's endpoint with the first connection's `surl` as its only + /// transport (docs 5, "Live results" 13), without the token, which the caller adds. + pub fn registration(&self) -> Option { + let surl = self.surl.as_deref()?; + let body = json!({ + "clientDescription": { + "appId": "TeamsCDLWebWorker", + "aesKey": "", + "languageId": "en-US", + "platform": "edge", + "templateKey": "TeamsCDLWebWorker_2.6", + "platformUIVersion": CLIENT_BUILD, + }, + "registrationId": self.endpoint, + "nodeId": "", + "transports": {"TROUTER": [{"context": "", "path": surl, "ttl": REGISTRATION_TTL_S}]}, + }); + let mut headers = registrar_headers(); + headers.push(("Content-Type".to_string(), "application/json".to_string())); + Some(HttpRequest { method: "POST".to_string(), url: self.registrar().to_string(), headers, body: body.to_string().into_bytes() }) + } + + /// The registration's removal at the session's end. + pub fn unregistration(&self) -> HttpRequest { + let url = format!("{}/{}", self.registrar().trim_end_matches('/'), self.endpoint); + HttpRequest { method: "DELETE".to_string(), url, headers: registrar_headers(), body: Vec::new() } + } + + fn registrar(&self) -> &str { + self.registrar.as_deref().unwrap_or(DEFAULT_REGISTRAR) + } + + /// The outcome of a registration: the next renewal, or another try soon. + pub fn registered(&mut self, ok: bool, now: i64) { + if ok { + self.renewal_failing_since = None; + self.renew_at = Some(now.saturating_add(RENEW_MS)); + } else { + self.renewal_failing_since.get_or_insert(now); + self.renew_at = Some(now.saturating_add(REGISTER_RETRY_MS)); + } + } + + /// The socket closed, failed to open or was closed for `0::`: the next one after a wait that + /// doubles with each failure. A second report of the same loss changes nothing. + pub fn lost(&mut self, now: i64) { + if matches!(self.link, Link::Down(_)) { + return; + } + let wait = (1000_i64 << self.failures.min(5)).min(MAX_BACKOFF_MS); + self.failures = self.failures.saturating_add(1); + self.link = Link::Down(now.saturating_add(wait)); + self.lost_since.get_or_insert(now); + self.ping_at = None; + } + + /// The earliest time something is due, for the session's timer. + pub fn next(&self) -> Option { + let down = match self.link { + Link::Down(at) => Some(at), + Link::Opening | Link::Up => None, + }; + [down, self.ping_at, self.renew_at, self.fail_at()].into_iter().flatten().min() + } + + fn fail_at(&self) -> Option { + [self.lost_since, self.renewal_failing_since].into_iter().flatten().min().map(|since| since.saturating_add(RECOVERY_MS)) + } + + /// What is due at `now`, one thing per call; `None` once nothing is. + pub fn tick(&mut self, now: i64) -> Option { + if self.fail_at().is_some_and(|at| now >= at) { + return Some(Tick::Failed); + } + if let Link::Down(at) = self.link + && now >= at + { + self.link = Link::Opening; + return Some(Tick::Connect); + } + if self.ping_at.is_some_and(|at| now >= at) { + self.ping_at = Some(now.saturating_add(PING_MS)); + self.pings = self.pings.saturating_add(1); + return Some(Tick::Send(format!("5:{}+::{{\"name\":\"ping\"}}", self.pings))); + } + if self.renew_at.is_some_and(|at| now >= at) { + self.renew_at = None; + return Some(Tick::Register); + } + None + } +} + +fn registrar_headers() -> Vec<(String, String)> { + [("X-Ms-Test-User", "False"), ("X-MS-Migration", "True"), ("Accept", "application/json, text/javascript")] + .into_iter() + .map(|(name, value)| (name.to_string(), value.to_string())) + .collect() +} + +fn on_host(url: &str, scheme: &str, host: impl Fn(&str) -> bool) -> bool { + Url::parse(url).is_ok_and(|u| { + u.scheme() == scheme + && u.username().is_empty() + && u.password().is_none() + && u.port_or_known_default() == Some(443) + && u.host_str().is_some_and(&host) + }) +} + +/// The most a compressed callback may inflate to. +const MAX_CALLBACK: u64 = 1024 * 1024; + +/// The body of a compressed callback (`X-Microsoft-Skype-Content-Encoding: gzip`): base64 of +/// gzip, else of zlib, as the probe read them (docs, "Live results" 19). +fn inflate(body: &str) -> Option { + use std::io::Read; + let bytes = crate::session::base64_decode(body)?; + let read = |reader: Box| -> Option> { + let mut out = Vec::new(); + reader.take(MAX_CALLBACK + 1).read_to_end(&mut out).ok()?; + (out.len() as u64 <= MAX_CALLBACK).then_some(out) + }; + let out = read(Box::new(flate2::read::GzDecoder::new(bytes.as_slice()))) + .or_else(|| read(Box::new(flate2::read::ZlibDecoder::new(bytes.as_slice()))))?; + String::from_utf8(out).ok() +} + +/// A downstream request (`3:::`): answered 200 (as the probe did, "Live results" 13), and handed +/// on only when it is a call callback. One that cannot be answered or read may have been a +/// callback, so it counts as lost. +fn request(data: &str) -> (Option, Incoming) { + let Ok(Value::Object(request)) = serde_json::from_str::(data) else { + return (None, Incoming::Lost); + }; + let Some(id) = request.get("id").filter(|id| id.is_number()) else { + return (None, Incoming::Lost); + }; + let url = request.get("url").and_then(Value::as_str).unwrap_or_default(); + if !url.contains("/callAgent/") { + return (Some(response(id, 200)), Incoming::Nothing); + } + // The join's endpoint capabilities ask for compressed callbacks (bit 128, as the web client; + // the acceptance comes compressed, "Live results" 19). One that cannot be inflated is answered + // as the client answers it. + let compressed = request.get("headers").and_then(Value::as_object).is_some_and(|headers| { + headers.iter().any(|(name, value)| { + name.eq_ignore_ascii_case("X-Microsoft-Skype-Content-Encoding") && value.as_str().is_some_and(|v| v.eq_ignore_ascii_case("gzip")) + }) + }); + let body = request.get("body").and_then(Value::as_str); + match if compressed { body.and_then(inflate) } else { body.map(str::to_string) } { + Some(body) => (Some(response(id, 200)), Incoming::Callback(Callback { url: url.to_string(), body })), + None => (Some(response(id, 400)), Incoming::Lost), + } +} + +fn response(id: &Value, status: u16) -> String { + format!("3:::{}", json!({"id": id, "status": status, "headers": {}, "body": ""})) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ENDPOINT: &str = "00000000-0000-4000-8000-0000000000e1"; + const SURL: &str = "https://pub-ent-synthetic.trouter.teams.microsoft.com/v4/f/c3ludGhldGlj/"; + const T0: i64 = 1_800_000_000_000; + + fn connected(surl: &str, extra: Value) -> String { + let mut args = json!({ + "surl": surl, + "connectparams": {"sig": "c2ln", "se": "1800000000", "reconnectUrl": "x", "serviceUrl": "y"}, + "ttl": 604_800, + }); + for (key, value) in extra.as_object().unwrap() { + args[key] = value.clone(); + } + format!("5:::{}", json!({"name": "trouter.connected", "args": [args]})) + } + + /// Standard base64, for the compressed callbacks. + fn base64(bytes: &[u8]) -> String { + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::new(); + for chunk in bytes.chunks(3) { + let n = chunk.iter().enumerate().fold(0u32, |n, (i, b)| n | u32::from(*b) << (16 - 8 * i)); + for i in 0..=chunk.len() { + out.push(char::from(ALPHABET[(n >> (18 - 6 * i) & 63) as usize])); + } + } + out + } + + /// A Trouter through its first `trouter.connected`. + fn up() -> Trouter { + let mut t = Trouter::new(ENDPOINT, "client"); + t.url("cor"); + t.handle(&connected(SURL, json!({})), T0).unwrap(); + t + } + + fn query(url: &str) -> Vec<(String, String)> { + let (_, q) = url.split_once('?').unwrap(); + form_urlencoded::parse(q.as_bytes()).into_owned().collect() + } + + fn get<'a>(pairs: &'a [(String, String)], key: &str) -> Option<&'a str> { + pairs.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()) + } + + #[test] + fn the_first_socket_goes_to_the_default_url_with_the_probes_query() { + let mut t = Trouter::new(ENDPOINT, "client"); + let url = t.url("cor-1"); + assert!(url.starts_with("wss://go.trouter.teams.microsoft.com/v4/c?"), "{url}"); + let q = query(&url); + let tc: Value = serde_json::from_str(get(&q, "tc").unwrap()).unwrap(); + assert_eq!(tc, json!({"cv": "2026.31.01.1", "ua": "TeamsCDL", "hr": "", "v": CLIENT_BUILD})); + assert_eq!(get(&q, "timeout"), Some("40")); + assert_eq!(get(&q, "epid"), Some(ENDPOINT)); + assert_eq!(get(&q, "cor_id"), Some("cor-1")); + assert_eq!(get(&q, "con_num"), Some("client_1")); + assert_eq!(get(&q, "ccid"), None); + assert_eq!(get(&query(&t.url("cor-2")), "con_num"), Some("client_2")); + } + + #[test] + fn open_asks_for_authentication_without_connectparams_the_first_time() { + let mut t = Trouter::new(ENDPOINT, "client"); + assert!(matches!(t.handle("1::", T0).unwrap(), (None, Incoming::Open))); + let frame = t.authenticate("tok"); + let event: Value = serde_json::from_str(frame.expose().strip_prefix("5:::").unwrap()).unwrap(); + assert_eq!(event["name"], "user.authenticate"); + assert_eq!(event["args"][0]["headers"], json!({"X-Ms-Test-User": "False", "Authorization": "Bearer tok", "X-MS-Migration": "True"})); + assert!(event["args"][0].get("connectparams").is_none()); + // The token never shows in a log. + assert_eq!(format!("{frame:?}"), "***"); + } + + #[test] + fn connected_gives_the_surl_and_the_registration() { + let mut t = Trouter::new(ENDPOINT, "client"); + let (reply, incoming) = t.handle(&connected(SURL, json!({})), T0).unwrap(); + assert!(reply.is_none()); + assert!(matches!(incoming, Incoming::Connected { first: true, same_surl: true })); + assert_eq!(t.surl(), Some(SURL)); + let reg = t.registration().unwrap(); + assert_eq!((reg.method.as_str(), reg.url.as_str()), ("POST", DEFAULT_REGISTRAR)); + let body: Value = serde_json::from_slice(®.body).unwrap(); + assert_eq!(body["registrationId"], ENDPOINT); + assert_eq!(body["transports"]["TROUTER"], json!([{"context": "", "path": SURL, "ttl": 900}])); + assert_eq!(body["clientDescription"]["templateKey"], "TeamsCDLWebWorker_2.6"); + assert!(reg.headers.iter().all(|(name, _)| !name.eq_ignore_ascii_case("authorization"))); + let unreg = t.unregistration(); + assert_eq!((unreg.method.as_str(), unreg.url), ("DELETE", format!("{DEFAULT_REGISTRAR}/{ENDPOINT}"))); + } + + #[test] + fn only_teams_own_registrar_gets_the_registration() { + let named = "https://teams.microsoft.com/registrar/prod/V2/registrations/"; + let mut t = Trouter::new(ENDPOINT, "client"); + t.handle(&connected(SURL, json!({"registrarUrl": named})), T0).unwrap(); + assert_eq!(t.registration().unwrap().url, named); + assert_eq!(t.unregistration().url, format!("{}/{ENDPOINT}", named.trim_end_matches('/'))); + for elsewhere in [ + "https://registrar.example.com/r", + "http://teams.microsoft.com/r", + "https://teams.microsoft.com:8443/r", + "https://u@teams.microsoft.com/r", + ] { + let mut t = Trouter::new(ENDPOINT, "client"); + t.handle(&connected(SURL, json!({"registrarUrl": elsewhere})), T0).unwrap(); + assert_eq!(t.registration().unwrap().url, DEFAULT_REGISTRAR, "{elsewhere}"); + } + } + + #[test] + fn a_reconnection_uses_the_reconnect_url_and_the_cached_connectparams() { + let reconnect = "wss://pub-ent-synthetic.trouter.teams.microsoft.com:443/v4/c"; + let mut t = Trouter::new(ENDPOINT, "client"); + t.url("cor"); + t.handle(&connected(SURL, json!({"reconnectUrl": reconnect})), T0).unwrap(); + t.lost(T0); + let url = t.url("cor-2"); + assert!(url.starts_with("wss://pub-ent-synthetic.trouter.teams.microsoft.com:443/v4/c?"), "{url}"); + assert_eq!(get(&query(&url), "con_num"), Some("client_2")); + let frame = t.authenticate("tok"); + let event: Value = serde_json::from_str(frame.expose().strip_prefix("5:::").unwrap()).unwrap(); + assert_eq!(event["args"][0]["connectparams"], json!({"sig": "c2ln", "se": "1800000000"})); + // The same surl again: the call's callbacks still arrive. + assert!(matches!(t.handle(&connected(SURL, json!({})), T0).unwrap().1, Incoming::Connected { first: false, same_surl: true })); + // No reconnect URL this time: the default one. + assert!(t.url("cor-3").starts_with(DEFAULT_URL)); + } + + #[test] + fn a_reconnect_url_off_the_trouter_hosts_is_not_used() { + for elsewhere in + ["wss://evil.example.com/v4/c", "ws://a.trouter.teams.microsoft.com/v4/c", "wss://trouter.teams.microsoft.com.example.com/v4/c"] + { + let mut t = Trouter::new(ENDPOINT, "client"); + t.handle(&connected(SURL, json!({"reconnectUrl": elsewhere})), T0).unwrap(); + assert!(t.url("cor").starts_with(DEFAULT_URL), "{elsewhere}"); + } + } + + #[test] + fn a_new_surl_after_a_reconnection_is_reported() { + let mut t = up(); + t.lost(T0); + let other = "https://pub-ent-other.trouter.teams.microsoft.com/v4/f/b3RoZXI/"; + assert!(matches!(t.handle(&connected(other, json!({})), T0).unwrap().1, Incoming::Connected { first: false, same_surl: false })); + // The registration keeps the first one. + assert_eq!(t.surl(), Some(SURL)); + } + + #[test] + fn a_connected_event_without_a_usable_surl_is_an_error() { + for args in [ + json!([]), + json!(["text"]), + json!([{"connectparams": {}}]), + json!([{"surl": "http://a.trouter.teams.microsoft.com/", "connectparams": {}}]), + json!([{"surl": 7, "connectparams": {}}]), + json!([{"surl": SURL}]), + json!([{"surl": SURL, "connectparams": "x"}]), + ] { + let frame = format!("5:::{}", json!({"name": "trouter.connected", "args": args})); + let mut t = Trouter::new(ENDPOINT, "client"); + assert!(matches!(t.handle(&frame, T0), Err(ProtoError::Incompatible(_))), "{args}"); + } + } + + #[test] + fn heartbeats_and_acks_are_answered() { + let mut t = up(); + assert!(matches!(t.handle("2::", T0).unwrap(), (Some(r), Incoming::Nothing) if r == "2::")); + assert!(matches!(t.handle(r#"5:7+::{"name":"audience.subscriptionresult"}"#, T0).unwrap(), (Some(r), Incoming::Nothing) if r == "6:::7")); + // An ack id that is not a short number gets no ack. + for frame in [r#"5:x+::{"name":"a"}"#, r#"5:+::{"name":"a"}"#, r#"5:123456789012345678901+::{"name":"a"}"#] { + assert!(t.handle(frame, T0).unwrap().0.is_none(), "{frame}"); + } + // The server's acks of our pings. + assert!(matches!(t.handle("6:::3", T0).unwrap(), (None, Incoming::Nothing))); + } + + #[test] + fn a_call_callback_is_answered_and_handed_on() { + let mut t = up(); + let url = format!("{SURL}callAgent/session/tag/call/acceptance/"); + let frame = format!("3:::{}", json!({"id": 5, "method": "POST", "url": url, "headers": {"MS-CV": "cv"}, "body": "{\"callAcceptance\":{}}"})); + let (reply, incoming) = t.handle(&frame, T0).unwrap(); + let reply: Value = serde_json::from_str(reply.unwrap().strip_prefix("3:::").unwrap()).unwrap(); + assert_eq!(reply, json!({"id": 5, "status": 200, "headers": {}, "body": ""})); + let Incoming::Callback(callback) = incoming else { panic!("not a callback") }; + assert_eq!((callback.url, callback.body.as_str()), (url, "{\"callAcceptance\":{}}")); + } + + #[test] + fn chat_notifications_are_answered_and_dropped_unread() { + let mut t = up(); + // The body is not even JSON: it is never parsed. + let frame = format!("3:::{}", json!({"id": 9, "method": "POST", "url": "https://synthetic/v4/f/x/messaging", "body": "not json"})); + assert!(matches!(t.handle(&frame, T0).unwrap(), (Some(r), Incoming::Nothing) if r.contains(r#""id":9"#) && r.contains(r#""status":200"#))); + } + + #[test] + fn a_callback_that_cannot_be_read_counts_as_lost() { + let mut t = up(); + let url = format!("{SURL}callAgent/s/t/conversation/rosterUpdate/"); + let gzip = format!("3:::{}", json!({"id": 1, "url": url, "headers": {"x-microsoft-skype-content-encoding": "GZIP"}, "body": "H4sI"})); + assert!(matches!(t.handle(&gzip, T0).unwrap(), (Some(r), Incoming::Lost) if r.contains(r#""status":400"#))); + // A compressed callback that inflates (gzip or zlib) is read; one past the limit is not. + let packed = |bytes: &[u8], gzip: bool| { + use std::io::Write; + let compressed = if gzip { + let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + e.write_all(bytes).unwrap(); + e.finish().unwrap() + } else { + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast()); + e.write_all(bytes).unwrap(); + e.finish().unwrap() + }; + base64(&compressed) + }; + for gzip in [true, false] { + let frame = format!( + "3:::{}", + json!({"id": 2, "url": url, "headers": {"X-Microsoft-Skype-Content-Encoding": "gzip"}, "body": packed(b"{\"callAcceptance\":{}}", gzip)}) + ); + let (reply, incoming) = t.handle(&frame, T0).unwrap(); + assert!(reply.unwrap().contains(r#""status":200"#)); + assert!(matches!(incoming, Incoming::Callback(c) if c.body == "{\"callAcceptance\":{}}")); + } + let bomb = format!( + "3:::{}", + json!({"id": 3, "url": url, "headers": {"X-Microsoft-Skype-Content-Encoding": "gzip"}, "body": packed(&vec![b' '; 2 * 1024 * 1024], true)}) + ); + assert!(matches!(t.handle(&bomb, T0).unwrap(), (_, Incoming::Lost))); + let no_body = format!("3:::{}", json!({"id": 2, "url": url})); + assert!(matches!(t.handle(&no_body, T0).unwrap(), (Some(r), Incoming::Lost) if r.contains(r#""status":400"#))); + // Without a numeric id it cannot be answered at all. + for frame in ["3:::", "3:::not json", "3:::[1]", r#"3:::{"url":"x/callAgent/"}"#, r#"3:::{"id":"5","url":"x/callAgent/","body":""}"#] { + assert!(matches!(t.handle(frame, T0).unwrap(), (None, Incoming::Lost)), "{frame}"); + } + } + + #[test] + fn message_loss_and_the_servers_close_are_reported() { + let mut t = up(); + assert!(matches!(t.handle(r#"5:::{"name":"trouter.message_loss","args":[{}]}"#, T0).unwrap().1, Incoming::Lost)); + assert!(matches!(t.handle("0::", T0).unwrap().1, Incoming::Closing)); + assert!(matches!(t.handle("0::timeout", T0).unwrap().1, Incoming::Closing)); + } + + #[test] + fn hostile_frames_never_panic() { + let mut t = up(); + let long = "5".repeat(100_000); + for frame in ["", ":", ":::", "5", "5:", "5::", "5:::", "5:::{", "5:::null", "5:::{\"name\":7}", "9:::x", "3", "\u{0}", "é:é:é:é", &long] + { + assert!(t.handle(frame, T0).is_ok(), "{frame:.20}"); + } + assert_eq!(t.surl(), Some(SURL)); + } + + #[test] + fn a_live_connection_pings_every_15_s() { + let mut t = up(); + assert_eq!(t.next(), Some(T0 + PING_MS)); + assert_eq!(t.tick(T0 + PING_MS - 1), None); + assert_eq!(t.tick(T0 + PING_MS), Some(Tick::Send(r#"5:1+::{"name":"ping"}"#.to_string()))); + assert_eq!(t.tick(T0 + PING_MS), None); + assert_eq!(t.tick(T0 + 2 * PING_MS), Some(Tick::Send(r#"5:2+::{"name":"ping"}"#.to_string()))); + } + + #[test] + fn a_lost_connection_is_retried_with_doubling_waits_then_fails_after_two_minutes() { + let mut t = up(); + t.lost(T0); + // A second report of the same loss (the server's `0::`, then the socket's close). + t.lost(T0); + // Every attempt fails; no ping is due on a socket that is gone. + let mut attempts = vec![T0]; + let failed = loop { + let at = t.next().unwrap(); + match t.tick(at) { + Some(Tick::Connect) => { + attempts.push(at); + t.lost(at); + } + Some(Tick::Failed) => break at, + other => panic!("{other:?} at {at}"), + } + }; + let gaps: Vec = attempts.windows(2).map(|w| w[1] - w[0]).collect(); + assert_eq!(gaps, [1000, 2000, 4000, 8000, 16_000, 30_000, 30_000]); + assert_eq!(failed, T0 + RECOVERY_MS); + } + + #[test] + fn a_reconnection_in_time_starts_over() { + let mut t = up(); + t.lost(T0); + assert_eq!(t.tick(T0 + 1000), Some(Tick::Connect)); + t.url("cor"); + t.handle(&connected(SURL, json!({})), T0 + 1500).unwrap(); + assert_eq!(t.next(), Some(T0 + 1500 + PING_MS)); + assert_ne!(t.tick(T0 + RECOVERY_MS + 5000), Some(Tick::Failed)); + // The next loss waits 1 s again. + t.lost(T0 + 200_000); + assert_eq!(t.next(), Some(T0 + 201_000)); + } + + #[test] + fn the_registration_is_renewed_and_a_failing_renewal_ends_the_session() { + let mut t = up(); + t.registered(true, T0); + assert_eq!(t.tick(T0 + RENEW_MS - 1), Some(Tick::Send(r#"5:1+::{"name":"ping"}"#.to_string()))); + while t.tick(T0 + RENEW_MS - 1).is_some() {} + assert_eq!(t.tick(T0 + RENEW_MS), Some(Tick::Register)); + let failing = T0 + RENEW_MS; + t.registered(false, failing); + assert_eq!(t.renew_at, Some(failing + REGISTER_RETRY_MS)); + t.registered(false, failing + REGISTER_RETRY_MS); + assert_eq!(t.tick(failing + RECOVERY_MS), Some(Tick::Failed)); + // A renewal that works in time clears it. + let mut t = up(); + t.registered(false, T0); + t.registered(true, T0 + REGISTER_RETRY_MS); + assert_ne!(t.tick(T0 + RECOVERY_MS), Some(Tick::Failed)); + } +} diff --git a/modules/test-echo/src/lib.rs b/modules/test-echo/src/lib.rs index 069c162..02e50d8 100644 --- a/modules/test-echo/src/lib.rs +++ b/modules/test-echo/src/lib.rs @@ -15,7 +15,9 @@ //! tool `writes` returns them) and succeed, except a `text` of "uncertain" (the module error), "fail" //! (a platform error) or "slow" (recorded, then busy until the call limit). `describe-target` //! returns the target the tool `set-target` stored for `{"conversation"}` (`{"participants"}` as -//! `[id, name]` pairs, `{"text"}` for any message), else participant "p1" (Pat) and "message ". +//! `[id, name]` pairs, `{"text"}` for any message, `{"title"}`), else participant "p1" (Pat), +//! "message " and the title "Echo "; for conversation "slow" it is busy for 1 s +//! first. //! The `restore` export succeeds only for account "known"; the `sign-in` export shows a device code, //! then opens the page `https://login.example.com/start` with the redirect prefix //! `https://login.example.com/done` and signs in the account named by the redirect's `account=` @@ -29,13 +31,22 @@ //! page is Microsoft's own sign-in page (for the manual check of the sign-in window); `sign-out` reports the //! account offline; `get-attachment` downloads an `http://` attachment id and returns fixed bytes //! named after any other. `wam-token` asks WAM for `{"scopes"}` and returns `{"token"}` or -//! `{"error"}`. +//! `{"error"}`. `rtc-open` opens a media connection with the data channel `{"label"}` and returns +//! `{"conn", "offer"}`; `rtc-accept` gives connection `{"conn"}` the answer `{"answer"}` and returns +//! `{}`; both return `{"error", "why"}` instead when refused, and `rtc-close` closes `{"conn"}`. The +//! manifest's media range is 52.112.0.0/14; data channel batches and states go to `host.log`. The +//! `end-sessions` and `restore` exports append their account to the storage keys "ended" and +//! "restored", which the tool `ended` returns as `{"ended", "restored"}`; `end-sessions` of account +//! "slow" is busy for 2 s first. //! //! Extra tools (the manifest's, for the core's registry): `peek` reads; it returns two items, one in //! `{"conversation"}` (else "c1") and one in "c2", one item without a conversation and some //! untrusted text; `{"answer": "big"}` returns a result over 1 MiB, `{"answer": "list"}` a JSON -//! array and `{"answer": "slow"}` the items after 1 s. Each call counts in the tool `peeks`. The others are never exposed: `poke` writes, `Bad-Name` breaks the name rule, -//! `with_account` declares `account`, `loose` takes any property, and `twin` is declared twice. +//! array and `{"answer": "slow"}` the items after 1 s. Each call counts in the tool `peeks`. `poke` +//! writes to its required `{"conversation"}` as `send` does (recorded under "writes", `{"text"}` +//! picks the outcome). The others are never exposed: `nudge` writes without naming a conversation, +//! `Bad-Name` breaks the name rule, `with_account` declares `account`, `loose` takes any property, +//! and `twin` is declared twice. #[cfg(target_arch = "wasm32")] mod guest { @@ -50,7 +61,7 @@ mod guest { } use bindings::exports::open_mess::module::{events, module}; - use bindings::open_mess::module::{host, http, sign_in, storage, types, websocket}; + use bindings::open_mess::module::{host, http, rtc, sign_in, storage, types, websocket}; use serde_json::{Value, json}; struct Echo; @@ -77,6 +88,13 @@ mod guest { } } + fn http_why(e: &http::HttpError) -> String { + match e { + http::HttpError::Blocked(why) | http::HttpError::Network(why) => why.clone(), + _ => String::new(), + } + } + fn call(account: &str, tool: &str, args_json: &str) -> Result { let args: Value = serde_json::from_str(args_json).map_err(|e| types::ModuleError::InvalidArgs(e.to_string()))?; let text = |key: &str| args.get(key).and_then(Value::as_str).unwrap_or_default().to_string(); @@ -119,7 +137,7 @@ mod guest { sign_in::show_device_code("ABCD-1234", "https://login.example.com/device"); json!({ "open-page": describe(sign_in::open_page("https://login.example.com/", "https://login.example.com/done")), - "wam-token": describe(sign_in::wam_token(&["scope".to_string()], true, None)), + "wam-token": describe(sign_in::wam_token(&["scope".to_string()], true, None, None)), }) } "sign-in-with" => { @@ -129,7 +147,7 @@ mod guest { "wam-token" => { let scopes: Vec = args.get("scopes").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(); - match sign_in::wam_token(&scopes, false, None) { + match sign_in::wam_token(&scopes, false, None, None) { Ok(token) => json!({ "token": token }), Err(e) => json!({ "error": sign_in_error(&e) }), } @@ -154,6 +172,22 @@ mod guest { }), } } + "rtc-open" => match rtc::open(&text("label")) { + Ok((conn, offer)) => json!({ "conn": conn, "offer": offer }), + Err(e) => json!({ "error": http_error(&e), "why": http_why(&e) }), + }, + "rtc-accept" => { + let conn = args.get("conn").and_then(Value::as_u64).and_then(|c| u32::try_from(c).ok()).unwrap_or_default(); + match rtc::accept(conn, &text("answer")) { + Ok(()) => json!({}), + Err(e) => json!({ "error": http_error(&e), "why": http_why(&e) }), + } + } + "rtc-close" => { + rtc::close(args.get("conn").and_then(Value::as_u64).and_then(|c| u32::try_from(c).ok()).unwrap_or_default()); + json!({}) + } + "ended" => json!({ "ended": stored("ended"), "restored": stored("restored") }), "trap" => std::process::abort(), // Opens a socket, stays busy while the server's frames pile up, then sends 40 frames. "ws-burst" => match websocket::open(&text("url"), &[]) { @@ -184,7 +218,7 @@ mod guest { "unread": "unread", "untrusted": {"title": format!("Echo {account}")}, }]}), - "send" | "edit_message" | "react" => { + "send" | "edit_message" | "react" | "poke" => { let mut writes = stored("writes").as_array().cloned().unwrap_or_default(); writes.push(json!({ "tool": tool, "args": args })); storage::set("writes", Value::from(writes).to_string().as_bytes()) @@ -249,7 +283,7 @@ mod guest { } } - /// The manifest's own tools: `peek`, and the ones the core never exposes. + /// The manifest's own tools: `peek`, `poke`, and the ones the core never exposes. fn extra_tools() -> Vec { let string = json!({"type": "string"}); let schema = |properties: Value| json!({"type": "object", "properties": properties, "additionalProperties": false}).to_string(); @@ -260,9 +294,20 @@ mod guest { writes, }; let peek = schema(json!({"conversation": string, "answer": string})); + let poke = json!({ + "type": "object", + "properties": {"conversation": string, "text": string}, + "required": ["conversation"], + "additionalProperties": false, + }); + let poke = types::ToolDecl { + description: "Pokes the conversation. It records its arguments as a write.".to_string(), + ..tool("poke", poke.to_string(), true) + }; vec![ tool("peek", peek.clone(), false), - tool("poke", peek.clone(), true), + poke, + tool("nudge", peek.clone(), true), tool("Bad-Name", peek.clone(), false), tool("with_account", schema(json!({"account": string})), false), tool("loose", json!({"type": "object", "properties": {}, "additionalProperties": true}).to_string(), false), @@ -271,6 +316,13 @@ mod guest { ] } + /// Appends `account` to the list stored under `key`. + fn remember(key: &str, account: &str) { + let mut list: Vec = stored(key).as_array().cloned().unwrap_or_default(); + list.push(Value::from(account)); + storage::set(key, Value::from(list).to_string().as_bytes()).ok(); + } + /// A JSON value the module stored under `key`, else null. fn stored(key: &str) -> Value { storage::get(key).and_then(|bytes| serde_json::from_slice(&bytes).ok()).unwrap_or_default() @@ -295,6 +347,7 @@ mod guest { scopes: vec![CHAT_SCOPE.to_string(), SHAREPOINT_SCOPE.to_string()], }), extra_tools: extra_tools(), + media: Some(types::MediaAccess { peers: vec!["52.112.0.0/14".to_string()] }), } } @@ -321,7 +374,7 @@ mod guest { if how == b"windows" { // The manual check of WAM: only the token's length is logged. let token = - sign_in::wam_token(&[CHAT_SCOPE.to_string()], false, None).map_err(|e| types::ModuleError::Platform(sign_in_error(&e)))?; + sign_in::wam_token(&[CHAT_SCOPE.to_string()], false, None, None).map_err(|e| types::ModuleError::Platform(sign_in_error(&e)))?; host::log(2, &format!("Windows token: {} bytes", token.len())); return Ok(types::AccountInfo { id: "windows".to_string(), display_name: format!("Windows token of {} bytes", token.len()) }); } @@ -338,6 +391,7 @@ mod guest { // Only account "known" has a session, so tests can tell restore from sign-out. fn restore(account: String) -> Result<(), types::ModuleError> { + remember("restored", &account); if account == "known" { Ok(()) } else { Err(types::ModuleError::AuthRequired(account)) } } @@ -345,6 +399,16 @@ mod guest { host::session(&account, host::SessionState::Offline); } + fn end_sessions(account: String) { + if account == "slow" { + let start = std::time::Instant::now(); + while start.elapsed() < std::time::Duration::from_secs(2) { + std::hint::spin_loop(); + } + } + remember("ended", &account); + } + fn call(account: String, tool: String, args_json: String) -> Result { call(&account, &tool, &args_json) } @@ -366,6 +430,12 @@ mod guest { } fn describe_target(_account: String, conversation: String, message: Option) -> Result { + if conversation == "slow" { + let start = std::time::Instant::now(); + while start.elapsed() < std::time::Duration::from_secs(1) { + std::hint::spin_loop(); + } + } let target = stored(&format!("target:{conversation}")); let participant = |pair: &Value| { let (id, name) = (pair.get(0)?.as_str()?, pair.get(1)?.as_str()?); @@ -376,7 +446,8 @@ mod guest { None => vec![types::Participant { id: "p1".to_string(), display_name: "Pat".to_string() }], }; let text = |id: String| target.get("text").and_then(Value::as_str).map_or_else(|| format!("message {id}"), str::to_string); - Ok(types::Target { conversation_title: format!("Echo {conversation}"), participants, target_text: message.map(text) }) + let title = target.get("title").and_then(Value::as_str).map_or_else(|| format!("Echo {conversation}"), str::to_string); + Ok(types::Target { conversation_title: title, participants, target_text: message.map(text) }) } } @@ -389,6 +460,15 @@ mod guest { host::log(2, &format!("ws {conn} closed {reason}")); } + fn on_rtc_data(conn: u32, messages: Vec>, dropped: u32) { + let texts: Vec = messages.iter().map(|m| String::from_utf8_lossy(m).into_owned()).collect(); + host::log(2, &format!("rtc {conn} data {} dropped {dropped}: {}", messages.len(), texts.join(","))); + } + + fn on_rtc_state(conn: u32, state: String) { + host::log(2, &format!("rtc {conn} state {state}")); + } + fn on_timer(id: u32) { host::log(2, &format!("timer {id}")); if id == 8 { diff --git a/modules/test-hog/src/lib.rs b/modules/test-hog/src/lib.rs index 4384842..9a47fff 100644 --- a/modules/test-hog/src/lib.rs +++ b/modules/test-hog/src/lib.rs @@ -28,6 +28,7 @@ mod guest { sign_in_hosts: Vec::new(), wam: None, extra_tools: Vec::new(), + media: None, } } @@ -41,6 +42,8 @@ mod guest { fn sign_out(_account: String) {} + fn end_sessions(_account: String) {} + fn get_attachment( _account: String, _conversation: String, @@ -68,5 +71,9 @@ mod guest { fn on_ws_closed(_conn: u32, _reason: String) {} fn on_timer(_id: u32) {} + + fn on_rtc_data(_conn: u32, _messages: Vec>, _dropped: u32) {} + + fn on_rtc_state(_conn: u32, _state: String) {} } } diff --git a/modules/test-spin/src/lib.rs b/modules/test-spin/src/lib.rs index c456374..7aa0b7d 100644 --- a/modules/test-spin/src/lib.rs +++ b/modules/test-spin/src/lib.rs @@ -27,6 +27,7 @@ mod guest { sign_in_hosts: Vec::new(), wam: None, extra_tools: Vec::new(), + media: None, } } @@ -40,6 +41,8 @@ mod guest { fn sign_out(_account: String) {} + fn end_sessions(_account: String) {} + fn get_attachment( _account: String, _conversation: String, @@ -66,5 +69,9 @@ mod guest { fn on_ws_closed(_conn: u32, _reason: String) {} fn on_timer(_id: u32) {} + + fn on_rtc_data(_conn: u32, _messages: Vec>, _dropped: u32) {} + + fn on_rtc_state(_conn: u32, _state: String) {} } } diff --git a/wit/module.wit b/wit/module.wit index 0770bac..3706ac2 100644 --- a/wit/module.wit +++ b/wit/module.wit @@ -36,6 +36,13 @@ interface types { scopes: list, } + // Address ranges of the media peers a module's `rtc` connections may reach (CIDR, e.g. + // "52.112.0.0/14"): no private ranges, a prefix of at least /8 for IPv4 and /16 for IPv6, at + // most 32 ranges (spec 2026-10-29-live-captions, 5.2). + record media-access { + peers: list, + } + record manifest { // Unique module name; accounts are bound to it (spec 8.3). name: string, @@ -47,6 +54,8 @@ interface types { sign-in-hosts: list, wam: option, extra-tools: list, + // Present only for a module that uses `rtc`; part of what the user approves. + media: option, } // An attachment's bytes, which the module fetches with `http.fetch`; during `get-attachment` @@ -127,6 +136,22 @@ interface websocket { close: func(conn: u32); } +// A peer connection with one data channel and nothing else (spec 2026-10-29-live-captions, +// 5.2): no audio or video is ever sent or received. Only for a module whose manifest has `media`. +interface rtc { + use http.{http-error}; + + // Opens a connection whose data channel is named `label`. Returns the connection and the + // host's offer: standard SDP with one data section and local candidates only, gathered fully. + open: func(label: string) -> result, http-error>; + // The remote answer (standard SDP). It must have the offer's single data section and nothing + // else; candidates outside the manifest's `media` ranges, private ones and ones the system + // proxy covers are dropped, and an answer with none left is refused (`blocked`, its reason + // starting with "proxy:" when the proxy took the last ones). + accept: func(conn: u32, answer: string) -> result<_, http-error>; + close: func(conn: u32); +} + interface storage { variant storage-error { quota-exceeded, @@ -162,8 +187,11 @@ interface sign-in { open-page: func(url: string, redirect-prefix: string) -> result; // Silent or interactive token from Windows WAM for the manifest's approved identity. `claims` // is the JSON of a claims challenge (CAE: a 401 with `WWW-Authenticate: ... claims=`), for a - // new token that satisfies it (spec 9.1). - wam-token: func(scopes: list, interactive: bool, claims: option) -> result; + // new token that satisfies it (spec 9.1). `account` names the module account the token is for: + // a call may name only its own account, an event any of the module's accounts signed in with + // Windows; without it a call gets its own account's token and an event the module's only one + // (spec 2026-10-29-live-captions, 5.2). + wam-token: func(scopes: list, interactive: bool, claims: option, account: option) -> result; } interface host { @@ -202,6 +230,11 @@ interface module { // Restores a session from stored secrets at daemon start. restore: func(account: string) -> result<_, module-error>; sign-out: func(account: string); + // Ends now everything of the account's that other people see (live captions: leaves the + // meetings), before the daemon stops, an update pauses the module's events or a scan stops or + // replaces the module. The instance may get calls afterwards. Limited to 5 s (spec + // 2026-10-29-live-captions, 4.3). + end-sessions: func(account: string); // Runs a shared tool (spec 8.5) or one of the manifest's extra tools. The writes `send`, // `edit_message` and `react` come only once the agent permissions and, where needed, the // user's approval allow them (spec 8.6); any successful result means done. @@ -218,11 +251,17 @@ interface events { on-ws-message: func(conn: u32, text: string); on-ws-closed: func(conn: u32, reason: string); on-timer: func(id: u32); + // Data channel messages that arrived since the last call, and how many were dropped (limits, + // a full queue) in that time. + on-rtc-data: func(conn: u32, messages: list>, dropped: u32); + // "connected", "failed" or "closed"; after "failed" or "closed" the connection is gone. + on-rtc-state: func(conn: u32, state: string); } world platform-module { import http; import websocket; + import rtc; import storage; import secrets; import sign-in;