--- source_sha: "91072bbd4d91" --- # Konfiguracja { #configuration } Cała konfiguracja jest zarządzana przez zmienne środowiskowe, wczytywane z `backend/.env` przy użyciu [pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/). Ustawienia są zdefiniowane w `app/core/config.py` i dostępne przez globalny obiekt `settings`: ```python from app.core.config import settings print(settings.MAX_UPLOAD_SIZE_MB) print(settings.DEBUG) ``` ## Na początek { #getting-started } `make install` tworzy `backend/.env` z `backend/.env.example`, kiedy tego pliku nie ma, i nigdy więcej go nie dotyka — więc na świeżym checkoucie nie ma czego kopiować, a na istniejącym nie ma czego stracić. Zanim cokolwiek trafi do sieci, w której jest ktoś jeszcze, ustaw wartości, które przykład dostarcza jako placeholdery: ```bash openssl rand -hex 32 # SECRET_KEY — signs every access token openssl rand -hex 32 # VAULT_MASTER_KEY — unwraps every credential stored at rest ``` !!! danger "`SECRET_KEY` jest dostarczany jako publicznie znany ciąg znaków" Pusty `VAULT_MASTER_KEY` cofa się do niego, żeby świeży checkout w ogóle działał. Oba są w porządku na laptopie i są całym bezpieczeństwem wdrożenia gdziekolwiek indziej. Jawne ustawienie `VAULT_MASTER_KEY` jest też tym, co pozwala przechowywanym sekretom przetrwać rotację `SECRET_KEY`. Konfiguracja odrzuca nieustawiony `VAULT_MASTER_KEY` poza `local`/`development`. ## Ustawienia projektu { #project-settings } | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `PROJECT_NAME` | `agenticos` | Nazwa wyświetlana projektu | | `API_V1_STR` | `/api/v1` | Prefiks wersji API | | `DEBUG` | `false` | Włącza tryb debugowania (szczegółowe błędy, auto-reload) | | `ENVIRONMENT` | `local` | Jedno z: `development`, `local`, `staging`, `production` | | `TIMEZONE` | `UTC` | Strefa czasowa IANA (np. `UTC`, `Europe/Warsaw`, `America/New_York`) | | `MODELS_CACHE_DIR` | `./models_cache` | Katalog na cache modeli ML | | `MEDIA_DIR` | `./media` | Katalog na przesłane pliki | | `MAX_UPLOAD_SIZE_MB` | `50` | Limit dokumentu w knowledge base i liczba, z której wyprowadzany jest opisany niżej sufit całego żądania. Dokument tej wielkości jest dzielony na chunki i embedowany, a nie trzymany w jednym kawałku | | `CHAT_MAX_UPLOAD_SIZE_MB` | `10` | Co można załączyć w czacie. Ma własne ustawienie zamiast tego powyżej, bo załącznik do agenta bez workspace'u jest wklejany w całości do promptu — więc obie powierzchnie zawodzą inaczej przy tym samym rozmiarze. Kiedyś było to zahardkodowane 10 MiB, którego żaden operator nie mógł podnieść ([#498](https://github.com/vstorm-co/agenticos/issues/498)); kontener frontendu czyta ten sam `CHAT_MAX_UPLOAD_SIZE_MB` w czasie działania, więc daj obu kontenerom jedną wartość albo composer odrzuci plik, który serwer by przyjął | | `EMBED_MAX_UPLOAD_SIZE_MB` | `5` | Co **obcy** może przesłać na hostowaną stronę. Sufit nałożony na `CHAT_MAX_UPLOAD_SIZE_MB`, nigdy sposób na jego obejście | | `ML_MAX_UPLOAD_SIZE_MB` | `25` | Ile może wysłać jedno wywołanie [usług ML](ml-services.md) — dokument do sparsowania, skan do rozpoznania, nagranie do transkrypcji. Własne ustawienie, bo bajty są parsowane albo wysyłane do silnika w obrębie jednego żądania, a nie zapisywane, więc limit dotyczy tego, ile może zająć pojedyncze synchroniczne wywołanie. Stoi na 25 MB klienta transkrypcji, najniższym limicie silnika za tą powierzchnią | | `ML_MAX_CONCURRENT_PARSES` | `4` | Ile dokumentów jeden worker parsuje naraz na potrzeby [usług ML](ml-services.md). Limit tempa liczy starty i nie widzi tego, co wciąż trwa, więc bez tego minutowy przydział wywołań OCR to tyle samo rozpoznawań w locie. Powyżej tej liczby wywołujący dostaje odmowę z `Retry-After`, a nie miejsce w kolejce | | `BROWSER_CDP_ALLOWED_HOSTS` | `[]` (empty) | Nazwy hostów, na których agent może prowadzić Chromium. `cdp_url` pochodzi ze speca agenta, który pisze każdy z uprawnieniem `edit` na tym agencie, więc adres jest kontrolowany przez tenanta, a żądanie wykonuje to wdrożenie — powód, dla którego jest to allowlista, a nie guard SSRF, przez który przechodzi każdy inny adres podany przez tenanta. Ten guard dopuszcza wyłącznie adresy _publiczne_, więc odrzuca izolowaną usługę przeglądarki w sieci samego wdrożenia, którą [capability](reference/capabilities.md#browser-automation) każe uruchomić, a przyjmuje debugger CDP wystawiony do internetu, co jest gorsze. Dopasowanie jest dokładne i niewrażliwe na wielkość liter, bez globów. Pusta wartość odrzuca automatyzację przeglądarki całkowicie | | `SMS_LINK_MAX_AGE_DAYS` | `90` | Jak długo numer telefonu pozostaje powiązany z kontem, zanim osoba musi go połączyć ponownie. Numer można przepisać lub przejąć, więc powiązanie SMS wygasa; `0` zostawia powiązania na zawsze. | | `MIRAGE_ALLOWED_HOSTS` | `[]` (empty) | Nazwy hostów, z którymi folder [wirtualnego terminala](reference/capabilities.md#mirage) może się połączyć, gdy autor wpisuje adres: serwer SSH, host w DSN Postgresa albo MongoDB, serwer IMAP. Adres pochodzi ze speca agenta albo z sekretu, który spec wskazuje, a połączenie nawiązuje to wdrożenie. Dopasowanie jest dokładne i niewrażliwe na wielkość liter. Publikacja sprawdza wpisany host, a każdy run sprawdza hosty w sekretach. Pusta wartość nie dopuszcza żadnego wpisanego adresu. Usługi ze stałym API, takie jak Dysk, GitHub i Notion, nie potrzebują wpisu | | `COMPUTER_DAEMON_ALLOWED_HOSTS` | `[]` (empty) | Nazwy hostów, na których agent z [obsługą komputera](reference/capabilities.md#computer-use) może prowadzić pulpit `computerd`. `daemon_url` jest w specu agenta, a run wysyła token daemona pod adres, który on wskazuje, więc to ta sama lista dozwolonych co `BROWSER_CDP_ALLOWED_HOSTS`: dokładne dopasowanie bez rozróżniania wielkości liter, bez globów. Sprawdzana przy publikacji i ponownie za każdym razem, gdy run otwiera daemona, więc usunięcie hosta zatrzymuje agentów już opublikowanych. Token nie jest ustawieniem: należy do organizacji i leży w jej vaulcie, więc każda organizacja może wskazać dozwolony host, a wejdzie tylko ta, która ma token tego pulpitu. Pusta odmawia backendu daemon; pulpit w sandboksie agenta nie potrzebuje wpisu | | `BROWSER_SIGN_IN_CDP_URL` | `""` (empty) | Przeglądarka, w której osoba loguje się do strony z **Ustawienia → Logowania**, gdzie żaden agent jej nie wskazuje - usługa `browser` z compose to `http://browser:9222`. Jej host musi być w `BROWSER_CDP_ALLOWED_HOSTS`. Strona musi mieć publiczny adres i oglądać ją może tylko osoba, która ją otworzyła. Puste ukrywa tę opcję | | `VISUAL_RENDER_CDP_URL` | `""` (puste) | Przeglądarka, którą [wizual](reference/capabilities.md#visuals) jest rysowany do obrazka tam, gdzie nie działa ramka - w odpowiedzi na Slacku, Telegramie czy Mattermoście i w miniaturze strony na dashboardzie. Serwis `browser` z compose to `http://browser:9222`; jego host musi być w `BROWSER_CDP_ALLOWED_HOSTS`. Rysunek niczego stamtąd nie osiąga: każde jego żądanie obsługuje wdrożenie albo je odrzuca. Puste wysyła do aplikacji czatu odpowiedź bez obrazka | | `BROWSER_EGRESS_PROXY` | `""` (empty); compose ustawia `http://browser-egress:3128` | Proxy, przez które przeglądarka wdrożenia łączy się z każdą stroną. Usługa `browser-egress` z compose to `http://browser-egress:3128` (`docker compose --profile browser up -d browser-egress`): sama rozwiązuje każdy host, odrzuca adresy prywatne, loopback, link-local i metadanych i łączy się z adresem, który sprawdziła, więc ani przekierowanie, ani DNS rebinding nie wprowadzi strony do wnętrza sieci. Używane przez każde przeglądanie bez proxy organizacji i nie na przeglądarce hostowanej. Wewnętrzne narzędzie, które agent ma przeglądać, wymaga wyjątku w `docker/browser-egress/squid.conf`. Puste wypuszcza przeglądarkę bezpośrednio: ustaw `BROWSER_EGRESS_PROXY=` w głównym `.env` tylko dla przeglądarki spoza compose, która nie dosięgnie proxy. Bez proxy każda strona jest sprawdzana przy pierwszym żądaniu w karcie, a potem przeglądarka sama rozwiązuje jej nazwę, więc nazwa, która później w tej samej karcie wskaże adres prywatny, nie zostanie wychwycona | | `OUTBOUND_PRIVATE_ALLOWLIST` | `[]` (empty) | Hosty i sieci z Twojej własnej sieci, do których żądanie może sięgnąć, choć rozwiązują się na adres prywatny, na przykład wiki, serwer Git albo serwer MCP: `["wiki", ".corp.example", "10.1.2.0/24"]`. Wpis to dokładny host (`wiki`, bez rozróżniania wielkości liter), sufiks z kropką na początku (`.corp.example` pasuje do `mcp.corp.example` i `a.b.corp.example`, ale nie do samego `corp.example`), adres IP (`10.0.0.5`, `fd00::1`) albo zakres CIDR (`10.1.2.0/24`). Jedna końcowa kropka jest pomijana, więc `wiki.` to `wiki`. Czytają ją kroki workflow `http.request`, `http.download` i `http.upload`, konektory `git`, `web` i `s3`, klient A2A oraz połączenia MCP. Łańcuch żądań dostaje listę tylko wtedy, gdy potrzebuje jej pierwszy URL, ten wpisany albo podpięty przez człowieka: łańcuch zaczynający się od hosta publicznego nigdy jej nie dostaje, więc przekierowanie, metadane OAuth, karta A2A ani crawl publicznej strony nie dosięgną hosta z listy. Każde konto każdej organizacji może sięgnąć do każdego portu dopuszczonego hosta, więc wpisuj tylko usługi, które same sprawdzają swoje poświadczenie: URL podpięty z publicznego wyzwalacza (webhooka, formularza, e-maila) albo z argumentów narzędzia agenta może wskazać dowolny host z listy. Adresy loopback, link-local, multicast i metadanych chmury są odrzucane niezależnie od listy: odrzucana jest nazwa z listy, która odpowiada takim adresem, wszędzie tam, gdzie nazwa jest rozwiązywana (kroki HTTP workflow, git, web, S3, A2A, przepływ OAuth MCP i własny URL połączenia MCP; nazwę z listy, która jeszcze się nie rozwiązuje, nadal można zapisać), a także nazwa z jedną odpowiedzią prywatną spoza listy, z podaniem tego adresu. Start odrzuca wpis błędnie zapisany, sięgający tych adresów (`localhost`, `.internal`, `169.254.169.254`, `127.0.0.0/8`, albo te same adresy zapisane przez dobrze znany prefiks NAT64 lub 6to4, jak `64:ff9b::/96` i `2002::/16`; własnego prefiksu NAT64 innej sieci nie da się rozpoznać, więc nie wpisuj go, jeśli tłumaczy na te adresy) albo wskazujący nazwą lub adresem hosta bazy danych, hosta Redis lub hosta samego API (sufiks obejmujący nazwę API jest dozwolony; zakres obejmujący rozwiązany adres któregoś z magazynów wychwytuje `doctor`). `agenticos cmd doctor` pokazuje ją jako `private network`: ostrzeżenie dla zakresu szerszego niż /16 (IPv4) albo /48 (IPv6), błąd, gdy zakres obejmuje bazę danych lub Redis. [Arkusz HIPAA](security.md#the-sheet) wymienia ją jako `private-network`. Pusta lista odrzuca każdy adres prywatny. Zobacz [Usługi we własnej sieci](deploy.md#services-on-your-own-network) i [połączenie](mcp.md#a-connection) | | `MCP_ALLOWED_HOSTS` | `[]` (empty) | Hosty, na które może wskazywać wyłącznie URL połączenia MCP, choć rozwiązują się na adres prywatny, na przykład usługa compose: `["postgres-mcp"]`. Pochodzi sprzed `OUTBOUND_PRIVATE_ALLOWLIST`, które obejmuje też MCP, i nie jest jego aliasem: host wpisany tylko tutaj nie jest osiągalny dla kroków HTTP workflow, konektorów ani A2A. Użyj go, gdy do hosta ma sięgać tylko MCP. Czyta je ten sam parser, więc przyjmuje te same formy (sufiks, taki jak `.corp.example`, adres IP, zakres CIDR), jedna końcowa kropka jest pomijana (`name.` to `name`), a nazwa z listy, która odpowiada adresem loopback, link-local albo metadanych chmury, jest odrzucana wszędzie tam, gdzie jest rozwiązywana, także dla własnego URL-a połączenia MCP; nazwę z listy, która jeszcze się nie rozwiązuje, nadal można zapisać. Host z listy może rozwiązywać się na adres prywatny, ale odpowiedź loopback, link-local albo metadanych nadal jest odrzucana: URL nadal musi mieć `http` albo `https`. Do serwera z listy można logować się przez OAuth: jego wykrywanie, rejestracja i żądania tokenów mogą sięgać do hostów z listy, przypięte, a logowanie do serwera publicznego nie sięga do żadnego z nich. Każde konto we wdrożeniu może sięgnąć do każdego portu hosta z listy, więc wpisuj tylko serwer, który sprawdza token. Wpis sięgający metadanych chmury, adresów link-local albo loopback, takich jak `169.254.169.254` czy `localhost`, albo wskazujący nazwą lub adresem bazę danych, Redis lub samo API, jest odrzucany przy starcie. Pusta lista odrzuca każdy adres wewnętrzny. Zobacz [połączenie](mcp.md#a-connection) | | `FILE_IO_MAX_WORKERS` | `8` | Rozmiar dedykowanej puli wątków, która wykonuje blokującą pracę na plikach — parsowanie uploadu oraz odczyt i zapis jego bajtów. Trzymana poza domyślnym współdzielonym executorem `asyncio`, który obsługuje też `bcrypt` i DNS przypiętych hostów, żeby fala uploadów nie zostawiła logowania i wychodzących żądań w kolejce za nimi ([#1108](https://github.com/vstorm-co/agenticos/issues/1108)). Podnieś ją na hoście, który parsuje wiele uploadów naraz. Musi być dodatnią liczbą całkowitą — `0` lub wartość ujemna zostaje odrzucona przy starcie | | `CHAT_CONVERT_TIMEOUT_SECONDS` | `60` | Jak długo może trwać pojedyncza konwersja DOC na tekst przez LibreOffice, zanim zostanie zabita. Znacznie poniżej 600s bazy wiedzy, bo to interaktywny upload | | `CHAT_CONVERT_MAX_CONCURRENCY` | `2` | Ile konwersji LibreOffice może działać naraz. Podproces omija `FILE_IO_MAX_WORKERS`, więc jest ograniczany osobno | | `CHAT_CONVERT_KILL_GRACE_SECONDS` | `5` | Oczekiwanie między `TERM` a `KILL`, gdy konwersja jest zatrzymywana siłą | | `CHAT_CONVERT_OUTPUT_MAX_BYTES` | `20971520` (20 MiB) | Limit pliku wyjściowego konwersji, sprawdzany przed odczytem | | `CHAT_TIFF_MAX_INLINE_PAGES` | `10` | Ile stron wielostronicowego TIFF jest konwertowanych na PNG i pokazywanych modelowi | | `CHAT_IMAGE_MAX_PIXELS` | `40000000` (~40 MP) | Limit pikseli na obraz sprawdzany przed dekodowaniem strony TIFF — ochrona przed bombą dekompresyjną | | `CHAT_ARCHIVE_MEMBER_MAX_BYTES` | `52428800` (50 MiB) | Limit rozmiaru po dekompresji na element pliku biurowego opartego na ZIP (ODF, PPTX) | | `CHAT_ARCHIVE_TOTAL_MAX_BYTES` | `104857600` (100 MiB) | Limit całkowitego rozmiaru po dekompresji pliku biurowego opartego na ZIP | | `CHAT_ARCHIVE_MAX_MEMBERS` | `2000` | Limit liczby elementów pliku biurowego opartego na ZIP | | `WORKSPACE_ARCHIVE_MAX_ENTRIES` | `5000` | Najwięcej plików i folderów, jakie może zawierać załączony `.zip`/`.tar.gz`, i najwięcej plików w zipie folderu workspace'u | | `WORKSPACE_ARCHIVE_MAX_BYTES` | `209715200` (200 MiB) | Najwięcej, do ilu może się rozpakować załączone archiwum, i najwięcej, ile może nieść zip folderu workspace'u | | `WORKSPACE_ARCHIVE_MAX_RATIO` | `100` | Najwięcej, do ilu może się rozpakować załączone archiwum, jako wielokrotność rozmiaru skompresowanego — powyżej jest odrzucane jako bomba dekompresyjna | | `WORKSPACE_ARCHIVE_MAX_MEMBER_BYTES` | `52428800` (50 MiB) | Najwięcej, do ilu może się rozpakować jeden plik w załączonym archiwum — tyle rozpakowywanie trzyma naraz w pamięci | | `CHAT_PARSED_TEXT_MAX_CHARS` | `1000000` | Limit przechowywanego wyciągniętego tekstu, żeby małe ZIP/OLE nie rozdęło wiersza | | `CHAT_PROMPT_TEXT_MAX_CHARS` | `200000` | Limit na plik dla sparsowanego tekstu wklejanego do promptu bez workspace'u | | `CHAT_TURN_TEXT_MAX_CHARS` | `500000` | Zbiorczy limit tekstu załączników w jednej turze | | `DEFAULT_ORG_MONTHLY_BUDGET_USD` | `100` | Miesięczny sufit wydatków, z którym startuje **nowa** organizacja, w USD, żeby nie była o jednego rozbieganego agenta od zaskakującego rachunku. Obowiązuje tylko przy tworzeniu; istniejące organizacje pozostają nietknięte i każdej organizacji można później wyczyścić limit. Musi być dodatni; zostaw **pusty**, aby organizacje startowały bez limitu (starsza postawa opt-in) | | `STAV_NOK_PER_USD` | puste | Kurs NOK za USD utrzymywany przez operatora do kosztów naliczonych przez Stav. Skończony i dodatni; np. `10` dzieli NOK przez 10 (przykład, nie bieżący kurs). USD nie wymaga ustawienia. Kurs nie jest pobierany; bez przeliczenia używany jest katalog cen, a nieznane modele pozostają częściowo wycenione | Aby wysyłać grafiki na kanały w produkcji, ustaw `VISUAL_RENDER_CDP_URL=http://visual-render:9222` i `BROWSER_CDP_ALLOWED_HOSTS=["visual-render"]` w `backend/.env`. `scripts/deploy.sh` włącza wtedy profil `visuals` w `docker-compose-prod.yml`; przy ręcznym uruchomieniu dodaj `--profile visuals`. Przeglądanie publicznych stron wymaga osobnej przeglądarki: ustaw `BROWSER_SIGN_IN_CDP_URL=http://browser:9222`, `BROWSER_EGRESS_PROXY=http://browser-egress:3128` i `BROWSER_CDP_ALLOWED_HOSTS=["browser","visual-render"]`. Wdrożenie włącza profil `browser`. Ustaw `browser_use.cdp_url` agenta na `http://browser:9222` i opublikuj jego szkic. Przeglądarka ma wyjście wyłącznie przez proxy; Squid odrzuca prywatne adresy i metadane. Do izolowanego rysowania zachowaj `VISUAL_RENDER_CDP_URL=http://visual-render:9222`. Brak ograniczenia domen agenta pozwala na publiczne strony; jawna lista domen nadal obowiązuje. Renderer nie publikuje portu i nie ma dostępu do internetu, obsługuje API i worker oraz ma limit 1 CPU i 1 GiB RAM. Publikowane obrazy backendu zawierają dodatek `browser`; własny obraz wymaga `EXTRAS="--extra browser"`. Jeżeli renderowanie zawiedzie, odpowiedź na kanale wyraźnie informuje, że nie udało się dołączyć grafiki. Każde renderowanie dopuszcza najwyżej 256 żądań i 32 oczekujące odpowiedzi; kafelki map podlegają też wspólnemu limitowi `RATE_LIMIT_MAP_TILES_PER_MINUTE`. ### Rozmiar żądania, a nie rozmiar pliku { #the-size-of-a-request-as-opposed-to-the-size-of-a-file } Każdy limit powyżej jest mierzony na bajtach, które już dotarły. FastAPI parsuje treść multipart, żeby rozwiązać parametr `UploadFile`, _zanim_ uruchomi się handler, więc zanim któryś z tych sufitów zostanie porównany z `len(data)`, treść jest już zbuforowana do pliku tymczasowego i wczytana do pamięci. Za sesją nie jest to duże ryzyko; na `POST /api/v1/embed/{key}/files`, do którego może sięgnąć obcy trzymający link, już jest. Dlatego żądanie deklarujące `Content-Length` większy niż `MAX_UPLOAD_SIZE_MB` plus 5 MiB zapasu na kopertę multipart dostaje odpowiedź **413**, zanim jego treść zostanie odczytana. Nie ma tu ustawienia: wartość idzie za `MAX_UPLOAD_SIZE_MB`, bo druga liczba, którą trzeba trzymać w zgodzie z pierwszą, to liczba, która kończy poniżej niej. **To tańsza połowa odpowiedzi, nie całość.** `Content-Length` ustawia wołający, a żądanie chunked nie deklaruje go wcale — te są przepuszczane i ograniczane limitami per trasa, które mierzą prawdziwe bajty. Wdrożenie, które chce gwarancji, a nie uprzejmości, ustawia `client_max_body_size` (nginx) albo odpowiednik na tym, co kończy jego połączenia; pliki compose uruchamiają uvicorna bez własnego takiego limitu. ## Uwierzytelnianie { #authentication } ### JWT { #jwt } | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `SECRET_KEY` | (insecure default) | Klucz podpisujący JWT. **Musi** zostać zmieniony w produkcji. Wygeneruj: `openssl rand -hex 32` | | `ACCESS_TOKEN_EXPIRE_MINUTES` | `30` | Czas życia access tokena | | `REFRESH_TOKEN_EXPIRE_MINUTES` | `10080` | Czas życia refresh tokena (7 dni) | | `REFRESH_REUSE_GRACE_SECONDS` | `60` | Jak długo po rotacji zużyty refresh token nadal dostaje odpowiedź - za każdym razem z tym samym następcą - żeby utracona odpowiedź albo seria odświeżeń nie kończyły sesji; `0` to wyłącza | | `ALGORITHM` | `HS256` | Algorytm podpisu JWT | Walidacja produkcyjna: `SECRET_KEY` musi mieć co najmniej 32 znaki i nie może używać wartości domyślnej przy `ENVIRONMENT=production`. ### Vault sekretów { #secret-vault } Każde poświadczenie, które platforma przechowuje w spoczynku — klucze providerów, tokeny botów kanałów, poświadczenia MCP i sekrety organizacji — jest zapieczętowane przez `app/core/vault.py`, którego koperta wyprowadzana jest z klucza głównego **i z właściciela** (organizacji albo członka, do którego należy osobiste połączenie). Szyfrogram jest więc bezużyteczny poza tenantem, dla którego go zapieczętowano. | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `VAULT_MASTER_KEY` | (empty, falls back to `SECRET_KEY`) | Klucz główny vaultu sekretów — skrót na wersję 1 z `VAULT_MASTER_KEYS`. Wymagany poza `local`/`development` (o ile nie ustawiono mapy poniżej), żeby vault na stagingu nie mógł wstać zapieczętowany pod opublikowaną wartością domyślną `SECRET_KEY`. Wygeneruj: `openssl rand -hex 32` | | `VAULT_MASTER_KEYS` | `{}` | Każdy klucz główny nadal w użyciu, po wersjach, jako JSON — `{"1": "", "2": ""}`. Najwyższa wersja pieczętuje nowe sekrety; starsze trzymają istniejące wiersze czytelnymi, dopóki `agenticos cmd vault-rotate` nie przepakuje ich na nowo. Kiedy jest ustawiona, jest całą prawdą: `VAULT_MASTER_KEY` musi być wtedy pusty. Zobacz [Sekrety](secrets.md#operations) | ### Klucz API { #api-key } | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `API_KEY` | `change-me-in-production` | Współdzielony klucz API do dostępu programistycznego | | `API_KEY_HEADER` | `X-API-Key` | Nazwa nagłówka HTTP dla klucza API | Walidacja produkcyjna: `API_KEY` nie może używać wartości domyślnej przy `ENVIRONMENT=production`. ### OAuth2 (Google) { #oauth2-google } | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `GOOGLE_CLIENT_ID` | (empty) | Client ID Google OAuth2 — logowanie **oraz** zgoda dla triggera Gmail | | `GOOGLE_CLIENT_SECRET` | (empty) | Client secret Google OAuth2 | | `GOOGLE_REDIRECT_URI` | `http://localhost:8000/api/v1/oauth/google/callback` | URL callbacku OAuth2 | | `FRONTEND_URL` | `http://localhost:3000` | URL frontendu dla przekierowań OAuth2 | | `DESKTOP_DEEP_LINK_SCHEME` | `agenticos` | Schemat, który rejestruje shell desktopowy dla logowania przekazanego przeglądarce systemowej ([Desktop](desktop.md#signing-in)). Callback buduje z niego przekierowanie, więc to ustawienie, a nie coś, co wybiera wołający | Skąd wziąć tę parę: [konsola Google Cloud](https://console.cloud.google.com/) → APIs & Services → Credentials → Create OAuth client ID → **Web application**. Autoryzowany redirect URI to callback **backendu**, a nie frontendu — `http://localhost:8000/api/v1/oauth/google/callback` domyślnie, a we wdrożeniu to, co mówi `GOOGLE_REDIRECT_URI`. Google wymienia kod z API, które dopiero potem odsyła przeglądarkę na `FRONTEND_URL`. Zarejestrowanie zamiast tego URL-a frontendu to błąd wart nazwania: ekran zgody działa, a callback zwraca 404. Przeglądarka jest odsyłana z jednorazowym, minutowym kodem, nigdy z samymi tokenami sesji: token w URL-u przekierowania trafia do paska adresu, do logu dostępu serwera frontendu i do `Referer` następnego żądania same-origin, a refresh token jest ważny przez tydzień. Frontend wymienia kod na parę tokenów serwer–serwer pod `POST /api/v1/oauth/exchange`, które realizuje go dokładnie raz. ### Logowanie jednokrotne (generyczne OIDC) { #single-sign-on-generic-oidc } Dowolny dostawca tożsamości publikujący dokument discovery: Microsoft Entra ID, Okta, Keycloak, Auth0, Authentik, Google Workspace przez swój endpoint OIDC. Firma hostująca to u siebie już jakiegoś używa i nie będzie zakładać lokalnych haseł swoim pracownikom — bez tego jej MFA i jej offboarding są rozwiązywane dwa razy. | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `OIDC_ISSUER` | (puste) | URL issuera. Puste znaczy brak SSO, a callback odpowiada 404 | | `OIDC_CLIENT_ID` | (puste) | Klient, którego dostawca wydał dla tego wdrożenia | | `OIDC_CLIENT_SECRET` | (puste) | Jego sekret | | `OIDC_REDIRECT_URI` | `http://localhost:8000/api/v1/oauth/oidc/callback` | Callback, zarejestrowany u dostawcy | | `OIDC_SCOPES` | `openid email profile` | Rozdzielone spacją. Dodaj własny scope dostawcy, jeśli potrzebuje go dla claimów | | `OIDC_VERIFIED_CLAIM` | (puste) | Trzeci claim akceptowany jako „ten adres jest potwierdzony”, dla dostawcy, który nazywa go po swojemu | | `OIDC_GROUPS_CLAIM` | (puste) | Claim z listą grup danej osoby, zwykle `groups`. Ustawiony sprawia, że każde logowanie stosuje [mapowania grup katalogowych](directory.md#directory-group-mappings); pusty zostawia członkostwa w spokoju. Odrzucany razem z `LDAP_URL` | Issuer to jedyny URL. Authorization, token, userinfo i JWKS biorą się z `/.well-known/openid-configuration`, który dostawca utrzymuje aktualny przez rotację kluczy czy przeniesienie endpointu — więc nie ma kolejnych adresów, które da się subtelnie pomylić. Flow to authorization code z PKCE. Claimy czytane są z ID tokena, a z endpointu **UserInfo** wtedy, gdy ID token ich nie niesie. Dostawca ma prawo trzymać `email` i swój claim weryfikacji w UserInfo i nie umieszczać żadnego w tokenie, więc czytanie samego tokena odrzucałoby w pełni zgodnego dostawcę o jedno żądanie od poprawnej tożsamości. Dwa przyciski po stronie frontendu, konfigurowane tam: | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `OAUTH_PROVIDERS` | `google` | Dodaj `oidc`, żeby pokazać przycisk SSO; samo `oidc` daje wyłącznie SSO. `ldap` dodaje formularz katalogowy, a `kerberos` przycisk logowania Windows, opisane niżej | | `OIDC_DISPLAY_NAME` | `SSO` | Jak przycisk nazywa dostawcę: `Acme SSO`, `Okta` | | `OIDC_ICON` | (puste) | `google`, `github` albo `microsoft` — znaki, które strona logowania już wozi. Cokolwiek innego rysuje zwykły klucz | Gdzie mieszka który issuer i co zarejestrować: | Dostawca | Issuer | Redirect URI rejestruj jako | | --- | --- | --- | | **Entra ID** | `https://login.microsoftonline.com//v2.0` | Redirect URI platformy **Web** w rejestracji aplikacji. Nadaj `openid`, `email`, `profile` w API permissions | | **Okta** | `https://.okta.com` (albo `/oauth2/` własnego serwera autoryzacji) | Sign-in redirect URI w aplikacji typu **Web** | | **Keycloak** | `https:///realms/` | Valid redirect URI w kliencie confidential z włączonym standard flow | Dwie rzeczy, których platforma wymaga od dowolnego dostawcy, na którego ją skierujesz: - **Adres musi być potwierdzony.** Akceptowane są dwa claimy: standardowy `email_verified` i `xms_edov` Entra ID, które Entra wysyła zamiast niego — `email_verified` nie emituje w ogóle, a `xms_edov` jest claimem **opcjonalnym**, włączanym na rejestracji aplikacji, więc tenant Entra, który go nie włączył, nie wysyła żadnego i każde logowanie jest odrzucane. `OIDC_VERIFIED_CLAIM` nazywa trzeci dla dostawcy, który mówi na to inaczej. Brak liczy się jako brak potwierdzenia: niepotwierdzony adres oznacza, że ktokolwiek u tego dostawcy może zgłosić czyjś służbowy adres, a lista dozwolonych domen niżej stoi na tym, że adres coś znaczy. - **Stabilny `sub`.** Konto jest kluczowane po nim, nie po adresie, więc osoba, która zmieni nazwisko albo której domenę ktoś wykupi, zachowuje swoją historię — a kolejny właściciel zwolnionego adresu jej nie dziedziczy. Zapisywany jest **z przestrzenią nazw issuera**, bo `sub` jest unikalny w obrębie swojego issuera i nigdzie indziej: przestawienie wdrożenia na inny tenant albo realm nie może wtedy zalogować nowego podmiotu na konto starego. Polityka rejestracji działa tu dokładnie tak, jak działa dla formularza rejestracji: wdrożenie `invite_only` odmawia logowania SSO komuś, kogo nikt nie zaprosił, a lista dozwolonych domen odmawia adresowi spoza niej — tym samym zdaniem na stronie logowania. Zobacz [Kto może się zarejestrować](deployment.md#who-may-register). Przy ustawionym `OIDC_GROUPS_CLAIM` o członkostwach decydują grupy dostawcy: [mapowania grup katalogowych](directory.md#directory-group-mappings) każdej organizacji dołączają ludzi z rolą i umieszczają ich w grupach, a zmapowana grupa wpuszcza pierwsze logowanie na wdrożeniu `invite_only` tak, jak robi to zaproszenie. Przekroczenie limitu grup w Entra ID (group overage) jest odrzucane, a nie czytane jako „brak grup” — zobacz [Claim grup przez OIDC](directory.md#the-groups-claim-over-oidc). SAML i SCIM nie są zaimplementowane. Większość dostawców tożsamości, których używa średniej wielkości firma, mówi po OIDC, a te ustawienia to całość tego, czego potrzebują. Katalogu, przed którym nie stoi żaden dostawca tożsamości, można użyć bezpośrednio — zobacz niżej. ### Logowanie katalogowe (LDAP) { #directory-sign-in-ldap } Logowanie kontem Active Directory, OpenLDAP albo FreeIPA przez bind jako to konto. [Logowanie katalogowe i grupy](directory.md) wyjaśnia logowanie, czego odmawia i jak czytane są grupy. Puste `LDAP_URL` je wyłącza, a route odpowiada 404. | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `LDAP_URL` | (puste) | `ldaps://host[:port]` albo `ldap://` ze StartTLS | | `LDAP_START_TLS` | `false` | Podnosi połączenie `ldap://` do TLS przed bindem | | `LDAP_ALLOW_PLAINTEXT` | `false` | Akceptuje `ldap://` bez StartTLS, co wysyła hasła otwartym tekstem. W przeciwnym razie odrzucane przy starcie | | `LDAP_CA_CERT_FILE` | (puste) | Pakiet PEM, względem którego weryfikowany jest certyfikat katalogu, dla firmowego CA | | `LDAP_BIND_DN` / `LDAP_BIND_PASSWORD` | (puste) | Konto serwisowe, którym logowanie przeszukuje katalog. Oba albo żadne; bez obu wyszukiwanie jest anonimowe | | `LDAP_USER_BASE_DN` | (puste) | Gdzie szukane są konta. Wymagane razem z `LDAP_URL` | | `LDAP_USER_FILTER` | dopasowuje `uid`, `sAMAccountName`, `userPrincipalName` albo `mail` | Musi zawierać `{username}`, które jest escapowane przed wstawieniem | | `LDAP_EMAIL_ATTRIBUTE` | `mail` | Adres konta | | `LDAP_NAME_ATTRIBUTE` | `displayName` | Nazwa wyświetlana konta | | `LDAP_ID_ATTRIBUTE` | `entryUUID` | Stabilny identyfikator, po którym kluczowane jest konto — `objectGUID` w Active Directory | | `LDAP_GROUP_ATTRIBUTE` | `memberOf` | Skąd czytane są grupy konta | | `LDAP_GROUP_BASE_DN` | (puste) | Szukaj grup tutaj, dla katalogu bez `memberOf` | | `LDAP_GROUP_FILTER` | `(member={dn})` | Wyszukiwanie grup, z `{dn}` albo `{username}` | | `LDAP_TIMEOUT_SECONDS` | `10` | Timeout połączenia i odczytu dla każdego żądania do katalogu | Frontend pokazuje formularz, gdy `OAUTH_PROVIDERS` zawiera `ldap`, i nazywa go według `LDAP_DISPLAY_NAME` (domyślnie `LDAP`). ### Logowanie Kerberos { #kerberos-sign-in } Zintegrowane logowanie Windows przez SPNEGO, które rozwiązuje principal z biletu przez powyższy katalog — więc wymaga `LDAP_URL` i obrazu zbudowanego z extra `kerberos`. Zobacz [Zintegrowane logowanie Windows](directory.md#integrated-windows-sign-in-kerberos). | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `KERBEROS_ENABLED` | `false` | Oferuje logowanie Kerberos. Odrzucane przy starcie bez `LDAP_URL` | | `KERBEROS_KEYTAB` | (puste) | Keytab z kluczem usługi. Puste używa domyślnego keytabu (`KRB5_KTNAME`, a w przeciwnym razie `/etc/krb5.keytab`) | | `KERBEROS_SERVICE_PRINCIPAL` | (puste) | `HTTP/@`. Puste akceptuje bilet dla dowolnego principala z keytabu | | `LDAP_KERBEROS_FILTER` | `(userPrincipalName={principal})` | Jak principal jest odnajdywany w katalogu: `{principal}` to `user@REALM`, `{username}` to część przed `@` | Frontend pokazuje przycisk, gdy `OAUTH_PROVIDERS` zawiera `kerberos`, i nazywa go według `KERBEROS_DISPLAY_NAME` (domyślnie `Kerberos`). ## Baza danych (PostgreSQL) { #database-postgresql } | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `POSTGRES_HOST` | `localhost` | Host PostgreSQL | | `POSTGRES_PORT` | `5432` | Port PostgreSQL | | `POSTGRES_USER` | `postgres` | Użytkownik PostgreSQL | | `POSTGRES_PASSWORD` | (empty) | Hasło PostgreSQL | | `POSTGRES_DB` | `agenticos` | Nazwa bazy danych | | `POSTGRES_SSLMODE` | (empty) | Szyfruj połączenie: `require`, `verify-ca` albo `verify-full`. Pusta wartość to plaintext. Zobacz [Szyfrowane połączenia](#encrypted-connections-tls) | | `DB_POOL_SIZE` | `5` | Rozmiar puli połączeń | | `DB_MAX_OVERFLOW` | `10` | Maksymalna liczba połączeń ponad pulę | | `DB_POOL_TIMEOUT` | `5` | Ile sekund żądanie czeka na wolne połączenie, zanim dostanie `503` z `Retry-After`. Celowo krótko: żądanie, które czekało 30 s, i tak było stracone, a kolejka czekających trzymała pulę pełną jeszcze po burście | Właściwości wyliczane: - `DATABASE_URL` -- asynchroniczny connection string (`postgresql+asyncpg://...`) - `DATABASE_URL_SYNC` -- synchroniczny connection string dla Alembica ## Redis { #redis } Pliki compose uruchamiają **Valkey** (`valkey/valkey:8-alpine`), fork Redisa 7.2 na BSD-3-Clause, a nie samego Redisa, który od 7.4.0 jest na RSALv2 albo SSPL-1.0 — żadna z nich nie jest licencją open source ([licencje](licenses.md)). Mówi tym samym protokołem na tym samym porcie, więc ustawienia poniżej, schemat `redis://` i nazwa usługi `redis` pozostają bez zmian, a wdrożenie, które skieruje je zamiast tego na zarządzanego Redisa, Valkey albo Elasticache, działa dokładnie tak jak wcześniej. Jest uruchamiany jako cache i nic więcej: `--save ''`, bez wolumenu, więc po każdym restarcie startuje pusty. Wszystko, co platforma tu trzyma — kubełki limitów, znaczniki deduplikacji kanałów, odpowiedzi o członkostwie — ma TTL i odtwarza się samo. Snapshot nie dawał nic, a kosztował jedną awarię: Valkey 8 jest forkiem Redisa 7.2 i odmawia wczytania RDB zapisanego przez Redisa 7.4, więc pierwsze wdrożenie na hoście, na którym działał `redis:7-alpine`, wpadło w pętlę restartów i zabrało ze sobą każdą usługę czekającą na cache. Zarządzana instancja, która jednak utrwala dane, jest w porządku; platforma tak czy inaczej na tym nie polega. | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `REDIS_HOST` | `localhost` | Host Redisa | | `REDIS_PORT` | `6379` | Port Redisa | | `REDIS_PASSWORD` | (none) | Hasło Redisa (opcjonalne) | | `REDIS_DB` | `0` | Numer bazy Redisa | | `REDIS_SSL` | `false` | Szyfruj połączenie (`rediss://`). Zobacz [Szyfrowane połączenia](#encrypted-connections-tls) | ## Szyfrowane połączenia (TLS) { #encrypted-connections-tls } Oba magazyny łączą się domyślnie plaintextem. Na pojedynczym hoście, gdzie Postgres i Redis są w tej samej sieci Dockera, jest to w porządku i tak właśnie działają dostarczane pliki compose. Przy zarządzanym Postgresie albo Redisie na innym węźle szyfrowanie połączenia jest tą kontrolą bezpieczeństwa transmisji, o którą audytor pyta najpierw (HIPAA §164.312(e), SOC 2 CC6.7). Ustawienie `POSTGRES_SSLMODE` buduje URL, który rozumie każdy ze sterowników — `?ssl=` dla asyncpg aplikacji, `?sslmode=` dla psycopg2 Alembica — a `REDIS_SSL` przełącza schemat Redisa na `rediss://`. `require` szyfruje połączenie; `verify-ca` i `verify-full` sprawdzają dodatkowo certyfikat serwera. `REDIS_SSL` wymaga też poprawnego łańcucha certyfikatów i zgodnej nazwy hosta w samym URL-u, zamiast zostawiać jedno i drugie domyślnym ustawieniom redis-py. !!! warning "Prywatne CA to plik, który czytają sterowniki, a nie systemowy magazyn zaufania" Żaden ze sterowników nie zagląda do magazynu zaufania kontenera, a obraz działa jako użytkownik bez uprawnień roota i bez entrypointu, który mógłby go przebudować. asyncpg i libpq czytają plik CA wskazany przez `PGSSLROOTCERT`; redis-py ufa temu zestawowi, na który wskazuje OpenSSL, a nadpisuje go `SSL_CERT_FILE`. Zamontuj CA raz i ustaw obie zmienne na ten plik — `verify-ca` i `verify-full` bez pierwszej z nich zawodzą, bo asyncpg szuka wtedy `~/.postgresql/root.crt` i niczego nie znajduje. !!! note "Każda usługa, która otwiera połączenie do magazynu, potrzebuje tej zmiany" `app`, `migrate` i `worker` łączą się z Postgresem i Redisem, a dostarczane pliki compose przypinają `POSTGRES_HOST=db` i `REDIS_HOST=redis` w `environment` każdej z nich, co wygrywa z plikiem env. Zarządzany magazyn to więc plik override sięgający wszystkich trzech, a nie linia w `.env`. ```yaml # docker-compose.managed.yml - a managed Postgres and Redis, verified against a # private CA. Run with `docker compose -f docker-compose.yml -f docker-compose.managed.yml up -d`. x-managed: &managed environment: POSTGRES_HOST: db.internal.example.com POSTGRES_SSLMODE: verify-full PGSSLROOTCERT: /run/tls/managed-ca.crt REDIS_HOST: redis.internal.example.com REDIS_SSL: "true" SSL_CERT_FILE: /run/tls/managed-ca.crt volumes: - ./ca/managed-ca.crt:/run/tls/managed-ca.crt:ro services: app: *managed migrate: *managed worker: *managed ``` Dołączone usługi `db` i `redis` dalej się uruchamiają, nieużywane; `agenticos cmd doctor` pokazuje, do którego magazynu faktycznie dotarło każde połączenie i czy było szyfrowane (`postgres: tls=on/off`, `redis: tls=on/off`, z `pg_stat_ssl` i ze schematu URL). ## E-mail (SMTP) { #email-smtp } Wdrożenie wysyła pocztę przez serwer SMTP, a takie, które nie ma go skonfigurowanego, nie zawodzi — działa, a każdy przepływ zależny od poczty po cichu się zatrzymuje i żaden z nich tego nie oznajmia: - **logowanie bez hasła i resety haseł** — maile z magic linkiem i z resetem to samoobsługowe drogi do konta; - **zaproszenia** — zapraszany adres nigdy nie dostaje maila (konsola mówi to teraz wprost, zamiast twierdzić, że wysłała, #1484); - **powiadomienia** — przekroczenie budżetu, prośba o zatwierdzenie, raport zużycia, informacja wysyłana, gdy administrator działa jako inne konto. | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `SMTP_HOST` | `localhost` | Host serwera SMTP | | `SMTP_PORT` | `587` | Port serwera SMTP. `587` i `25` negocjują STARTTLS; `465` otwiera TLS od początku | | `SMTP_USER` | (empty) | Nazwa użytkownika, którą uwierzytelnia się relay, obok `SMTP_PASSWORD`. Zostaw pustą dla relaya bez uwierzytelniania | | `SMTP_PASSWORD` | (empty) | Hasło do tej nazwy użytkownika | | `SMTP_TLS` | `true` | Czy szyfrować połączenie. Schemat wybiera port — STARTTLS na `587`, niejawny TLS na `465` — chyba że `SMTP_TLS_MODE` mówi inaczej. Ustaw `false` tylko dla nieszyfrowanego relaya, na przykład lokalnego serwera na `25` | | `SMTP_TLS_MODE` | `auto` | Jak otwierane jest szyfrowane połączenie. `auto` pozwala wybrać portowi; `implicit` otwiera TLS od pierwszego bajtu, a `starttls` negocjuje podniesienie, niezależnie od portu. Ignorowane, gdy `SMTP_TLS=false` | | `EMAIL_FROM` | `noreply@agenticos.com` | Adres w polu `From` każdej wiadomości | | `EMAIL_FROM_NAME` | `agenticos` | Nazwa wyświetlana obok tego adresu | !!! note "Jak szyfrowane jest połączenie" `SMTP_TLS` to przełącznik włącz/wyłącz; schemat wybiera port. Dostarczana wartość domyślna — `587` z `SMTP_TLS=true` — negocjuje STARTTLS, czyli to, czego oczekuje zgodny ze standardem serwer submission. Użyj `465` dla serwera, który chce zamiast tego niejawnego TLS, i `SMTP_TLS=false` na `25` dla relaya plaintext. Serwer mówiący niejawnym TLS na porcie innym niż `465` — powiedzmy `8465` — potrzebuje `SMTP_TLS_MODE=implicit`, bo `auto` zaproponowałoby mu handshake plaintext i każda wysyłka by zawiodła. `starttls` to przypadek odwrotny. ## Praca w tle { #background-work } Zadania czekają w kolejce w PostgreSQL i wykonują się w workerze, `python -m app.worker`, z tego samego obrazu co API. Nie ma serwera, na który trzeba go skierować, ani klucza: worker sięga do tej samej bazy co API. Każde ustawienie poniżej dotyczy jednego procesu workera. | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `WORKER_POOLS` | `agent,eval,ingest,system,workflow` | Pule obsługiwane przez ten proces. Uruchom drugiego workera z `ingest`, żeby parsowanie miało własny host. Do listy ustawionej, zanim pula `workflow` istniała, trzeba ją dopisać, inaczej żaden worker nie wykona węzłów workflowów | | `WORKER_AGENT_CONCURRENCY` | `10` | Runy agentów i odpalenia triggerów naraz | | `WORKER_EVAL_CONCURRENCY` | `4` | Eval runy naraz. Każdy trzyma swój slot przez cały run, więc evale mają własną pulę i nigdy nie zajmują slotu potrzebnego runowi agenta | | `WORKER_WORKFLOW_CONCURRENCY` | `5` | Węzły workflowów naraz. Dobierz pamięć workera do tylu węzłów trzymających największy plik, jaki węzeł może trzymać | | `WORKER_SYSTEM_CONCURRENCY` | `4` | Sprzątanie, porządki i raporty naraz | | `WORKER_INGEST_CHILDREN` | `2` (`1` w dostarczanych plikach compose) | Procesy potomne do parsowania i embeddingów, każdy z jednym zadaniem naraz | | `WORKER_INGEST_MEMORY_MB` | `1536` | Ile może zająć jeden proces potomny razem z tym, co uruchomił, zanim zatrzyma go watchdog | | `WORKER_INGEST_MAX_JOBS` | `100` | Zadania, które proces potomny wykona, zanim zostanie zastąpiony | | `WORKER_LEASE_SECONDS` | `60` | Jak długo pobrane zadanie jest trzymane bez heartbeatu | | `WORKER_BREAKER_DEATHS` | `5` | Śmierci workera z rzędu, w zadaniach jednego rodzaju, które wstrzymują ten rodzaj | | `WORKER_HEALTH_FILE` | `/tmp/agenticos-worker-alive` | Plik, który czyta sonda kontenera | | `WORKER_MEMORY_LIMIT` | `4g`, `2560M` w produkcyjnym compose | Własny limit pamięci kontenera - zmienna compose, nie ustawienie | | `BACKGROUND_JOB_RETENTION_DAYS` | `14` | Zakończone zadania przechowywane dla strony Background jobs | **Pamięć planuj według procesów potomnych, a nie liczby zadań.** Worker trzyma około 450 MB na zaimportowaną aplikację, a każdy proces potomny ingestii drugie tyle plus dokument, który parsuje. `WORKER_INGEST_MEMORY_MB` celowo leży poniżej limitu kontenera: watchdog zatrzymuje proces potomny, który go przekroczy, i zapisuje powód przy zadaniu, podczas gdy OOM killer jądra zostawiłby tylko martwy proces. Na hoście z zapasem pamięci podnieś `WORKER_INGEST_CHILDREN`, a razem z nim limit kontenera. **Zadanie, które zabija swój proces, nie może się zapętlić.** Jego próba jest liczona w chwili pobrania, więc proces potomny zabity przez jądro w trakcie parsowania zużył jedną, a po `max_attempts` zadanie jest martwe. Rodzaj, którego zadania zabijają swojego workera `WORKER_BREAKER_DEATHS` razy z rzędu, zostaje wstrzymany, a każdy właściciel wdrożenia dostaje powiadomienie; nic go nie pobiera, dopóki któryś z nich nie wznowi go na stronie Background jobs. Licznik jest prowadzony osobno dla każdej organizacji: tenant, którego zadania wciąż zabijają workera, wstrzymuje rodzaj tylko dla siebie, a zadania bez organizacji dzielą jeden licznik dla całego wdrożenia. **To, co zadanie zostawia, zostaje domknięte, nawet gdy raz się nie uda.** Zadanie, które kończy się na dobre, może zostawić dokument w stanie `processing` albo log synchronizacji w stanie `running`. Worker domyka je, gdy zadanie się kończy, a rescuer ponawia nieudane domknięcie - np. gdy baza była niedostępna - przez dobę. Worker, który umrze, oddaje swoje zadania w ciągu `WORKER_LEASE_SECONDS`: ratowanie u innego workera przejmuje wygasłą dzierżawę i zapisuje zadanie jako śmierć workera. Deploy nie kosztuje zadania próby - worker przestaje pobierać, pozwala trwającym zadaniom skończyć się przez 30 sekund i oddaje resztę. Pliki compose dają mu 45 sekund, zanim go zabiją. Health check kontenera czyta `WORKER_HEALTH_FILE`, który heartbeat dotyka po każdym uderzeniu, które dotarło do bazy, więc worker żywy, ale odcięty od Postgresa, jest `unhealthy`. To plik, a nie endpoint HTTP, bo worker niczego nie serwuje. W `backend/Dockerfile` nie ma `HEALTHCHECK`. Obraz jest uruchamiany jako różne procesy - API, worker i migracje - a sonda dla jednego byłaby stałym fałszywym alarmem dla drugiego, więc każda definicja usługi niesie własną. ### Wygasanie zatwierdzeń { #approval-expiry } | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `APPROVAL_EXPIRY_HOURS` | `72` | Jak długo zaparkowane wywołanie narzędzia czeka, zanim cogodzinne zamiatanie odrzuci je przez timeout | | `QUESTION_EXPIRY_HOURS` | `24` | Jak długo run czeka na odpowiedź na pytanie agenta, zanim to samo zamiatanie go zakończy | Trzy dni, bo musi objąć weekend: zatwierdzenie, które przychodzi w piątek po południu, jest tym, o którym nikt nie decyduje, a wygaszenie go w sobotę byłoby wygaszeniem za to, że ktoś zapytał o złej porze. Skróć je tam, gdzie kolejka jest pilnowana w godzinach pracy i nieaktualna prośba jest gorsza niż wolna; wydłuż tam, gdzie zatwierdzenia są cotygodniowym rytuałem. Wygaśnięcie wywołania **kończy też jego run** — zobacz [Governance](governance.md#a-decision-nobody-makes), co to rozstrzyga i co celowo zostawia w spokoju. ### Zbieranie porzuconych runów { #stale-run-reaping } | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `RUN_HEARTBEAT_SECONDS` | `15` | Jak często proces wykonujący run oznacza go jako żywy, gdy model pracuje | | `STALE_RUN_REAPED_AFTER_SECONDS` | `120` | Jak długo run w stanie `running` może nie dawać znaku życia, zanim zamiatanie co minutę uzna, że jego proces umarł, i zakończy go jako `failed`. Co najmniej trzy `RUN_HEARTBEAT_SECONDS`, co sprawdza start; zero lub mniej wyłącza zamiatanie | Wiersz runa jest commitowany przed wywołaniem jego modelu, więc worker zabity w trakcie runa zostawia go w stanie `running` bez niczego, co by go dokończyło. Sufit nie musi być dokładny — żywy run, który zamiatanie mimo to przestawi, zostaje przestawiony z powrotem przez własny zapis końcowy — więc ustaw go daleko za swoim najdłuższym uprawnionym runem i nie bliżej. Zobacz [Governance](governance.md#a-run-whose-process-died). ### Runy workflowów { #workflow-runs } | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `WORKFLOW_RUN_MAX_INPUT_BYTES` | `262144` | Największy ładunek, z jakim można uruchomić run, jako zwarty JSON. Jest przechowywany w runie dla jego węzła `core.input`, więc większy zostaje odrzucony z `413`, zanim run zostanie przyjęty | | `WORKFLOW_RUN_MAX_FILE_BYTES` | `1073741824` | Najwięcej bajtów plików, jakie może zapisać jeden run, wszystkie jego kroki plikowe razem. Krok, którego plik by go przekroczył, kończy się błędem `FILE_QUOTA_EXCEEDED` i niczego nie zapisuje | | `WORKFLOW_ORG_MAX_FILE_BYTES` | `21474836480` | To samo dla jednej organizacji, we wszystkich runach, które nadal przechowują swoje pliki | | `WORKFLOW_WEBHOOK_RESPONSE_TIMEOUT_SECONDS` | `30` | Jak długo dostarczenie webhooka, którego graf ma krok Respond to webhook, czeka na odpowiedź, zanim dostanie `202`, a run toczy się dalej. Proxy przed nim z krótszym limitem odczytu przerywa czekanie wcześniej | | `WORKFLOW_DISPATCH_LEASE_SECONDS` | `120` | Jak długo trzyma się claim, który worker bierze na węzeł workflowu, zanim zostanie uznany za porzucony. Worker odnawia go co jedną trzecią tego czasu, dopóki węzeł działa, więc ta wartość ogranicza, jak długo martwy worker pozostaje niezauważony, a nie jak długo może działać węzeł | | `WORKFLOW_RETRY_CEILING` | `3` | Największa liczba nieudanych lub przerwanych prób węzła: prób zakończonych błędem i prób przerwanych śmiercią workera. Próba, która czeka - na zatwierdzenie albo na backoff, o który poprosił węzeł - się nie liczy, więc to, jak często węzeł czeka, ogranicza tylko termin runa, jego budżet albo anulowanie | | `WORKFLOW_RETRY_BACKOFF_BASE_SECONDS` | `5` | Czas oczekiwania przed pierwszym ponowieniem węzła; oczekiwanie przed każdym kolejnym ponowieniem jest dwa razy dłuższe | | `WORKFLOW_RETRY_BACKOFF_MAX_SECONDS` | `300` | Najdłuższe, do jakiego może urosnąć pojedyncze oczekiwanie. Te trzy ustawienia dotyczą węzła, którego `policy.retry` nie ustala własnych | | `WORKFLOW_NODE_MAX_OUTPUT_BYTES` | `10485760` | Łączny kompaktowy JSON UTF-8 wyniku próby, wyjścia runa i odpowiedzi webhooka. Przekroczenie daje `NODE_OUTPUT_TOO_LARGE` bez automatycznego retry, z zachowaniem kosztu. Split out sprawdza limit 10 000 rozwiniętych elementów i konserwatywny rozmiar przed kopiowaniem | | `WORKFLOW_RUN_MAX_INLINE_RESULT_BYTES` | `67108864` | Rezerwacje wejść, snapshotów draftu, payloadów Wait i wyników runa; małe końcowe rekordy kontrolne są wyjątkiem | | `WORKFLOW_ORG_MAX_INLINE_RESULT_BYTES` | `1073741824` | Rezerwacje payloadów wszystkich zachowanych runów organizacji; retencja zwalnia je przez liczniki | | `WORKFLOW_FOREACH_MAX_ITEMS` | `1000` | Najdłuższa lista, po której iteruje `control.foreach`. Dłuższa kończy pętlę błędem `FOREACH_TOO_MANY_ITEMS` zamiast zostać przycięta | | `WORKFLOW_FOREACH_MAX_MANIFEST_BYTES` | `1048576` | Największa lista, jaką pętla zamraża, jako JSON. Większa kończy pętlę błędem `FOREACH_LIST_TOO_LARGE` | | `WORKFLOW_FOREACH_MAX_OUTPUT_BYTES` | `10485760` | Najnowsze wyniki yield i nieobsłużone błędy wraz z narzutem kolekcji, mierzone przed pobraniem JSON. Większa kolekcja kończy się `FOREACH_OUTPUT_TOO_LARGE`; zwracaj mniejsze wartości lub referencje plików | | `WORKFLOW_FOREACH_MAX_DEPTH` | `3` | Jak głęboko mogą się zagnieżdżać pętle. Grafu z głębszymi pętlami nie da się opublikować | | `WORKFLOW_RUN_MAX_NODE_RUNS` | `10000` | Najwięcej przebiegów węzłów, jakie może utworzyć jeden run, łącznie z iteracjami pętli. Iteracja, która by go przekroczyła, kończy run błędem `NODE_RUN_LIMIT` | | `WORKFLOW_MAX_ACTIVE_NODE_RUNS_PER_ORG` | `5000` | Ile oczekującej lub działającej pracy węzłów może naraz trzymać jedna organizacja. Start rezerwuje na poczet tego limitu liczbę węzłów swojego grafu, a start ponad limit jest odrzucany z `429`, dopóki działająca praca nie zejdzie. Musi wynosić co najmniej `WORKFLOW_GRAPH_MAX_NODES` | | `WORKFLOW_MAX_ACTIVE_NODE_RUNS_PER_PRINCIPAL` | `2000` | Ten sam limit dla pojedynczego wołającego, liczony we wszystkich organizacjach, w których uruchamia runy, żeby osoba mogąca tworzyć organizacje nie zwielokrotniła swojego przydziału, rozkładając runy między nie. Musi wynosić co najmniej `WORKFLOW_GRAPH_MAX_NODES` | Limit runów na wołającego (`RATE_LIMIT_RUN_PER_MINUTE`) nalicza jeden token na start i nie odróżni grafu z jednym węzłem od grafu z pięciuset. Te dwa limity to potrafią: ograniczają oczekującą i działającą pracę węzłów za współdzielonym runnerem, żeby jeden wołający nie mógł uruchomić wielu szerokich grafów poniżej limitu tempa i narastać zaległości, które zagłodzą innych najemców. Run workflowu to łańcuch jobów. Każda próba węzła to jeden job `workflow-dispatch-node` w puli workflow, zakolejkowany w tej samej transakcji co wiersz wysyłki węzła, więc run nigdy nie startuje bez swojego joba, a wycofany start go nie zostawia. Timer albo opóźnienie ponowienia to ten sam job z `run_after` ustawionym na czas, w którym ma ruszyć. Pozycję runu trzyma wiersz w bazie, a nie job: job wykonuje się raz, a worker, który padł w trakcie węzła, jest odzyskiwany przez poniższe sweepy, nie przez ponawianie joba. W puli system działa pięć sweepów: `workflow-dispatch-poll` (co minutę) kolejkuje job dla gotowego węzła, którego własny job się nie wykonał, `workflow-reconcile` (co 30 sekund) odzyskuje claimy i próby pozostawione przez martwego workera, `workflow-schedules-check` (co minutę) uruchamia harmonogramy workflowów, `workflow-table-triggers` (co 10 sekund) obsługuje wyzwalacze na rekordach tabel, a `workflow-run-retention` (codziennie) usuwa runy po okresie retencji ich workflowu. Joby węzłów mają własną pulę, więc rozgałęzienie nie zajmie miejsc, na które czekają uruchomienia agentów, a agent, który woła workflow i czeka na niego, nie czeka na miejsce we własnej puli. `WORKER_WORKFLOW_CONCURRENCY` ogranicza liczbę węzłów wykonywanych naraz na każdym workerze. Run, na którego wynik ktoś czeka (odpowiedź webhooka, czat, API), wyprzedza taki, który uruchomił się sam, a workflow wołany przez inny dziedziczy priorytet runu, który go zawołał. Węzeł działa w pętli zdarzeń i pamięci samego workera: dobierz pamięć workera do `WORKER_WORKFLOW_CONCURRENCY` węzłów, z których każdy trzyma największy plik, jaki węzeł może trzymać (`WORKFLOW_RUN_MAX_FILE_BYTES`), bo worker zabity przez jądro traci wszystkie joby, które wykonywał. Takie śmierci nie liczą się do `WORKER_BREAKER_DEATHS`: jeden crash kończy wszystkie węzły, które worker trzymał, a węzeł, który ciągle zabija workera, zatrzymuje własny limit ponowień workflowu. Strona Background jobs pokazuje jeden job na próbę węzła, a sweepy dokładają około 14 400 wierszy dziennie, przechowywanych przez `BACKGROUND_JOB_RETENTION_DAYS`. Anulowania zakolejkowanego joba węzła tam nie ma: anuluj run. ### Tura czatu zostawiona w trakcie { #a-chat-turn-left-running } | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `CHAT_DETACHED_TURN_MINUTES` | `60` | Jak długo tura czatu trwa po tym, jak osoba, która pytała, wyszła - zamknięta karta, zamknięta aplikacja, zerwane połączenie - zanim zostanie zatrzymana; najwyżej `120`, bo dłużej lista rozmów przestaje pokazywać turę jako trwającą | Odpowiedź trafia do rozmowy, a jej osoba dostaje powiadomienie, więc to ogranicza wydatki na odpowiedź, której może nikt nie przeczytać, a nie wyłącza czegokolwiek. Musi objąć Twoją najdłuższą turę researchu albo przeglądania; run i tak trzyma się budżetu agenta. Zobacz [Konsola](console.md#chat). Limity plików i tabel organizacji nie ograniczają łącznego storage deploymentu. Bez `max_organizations_per_user` konto może posiadać dowolnie wiele organizacji i mnożyć limity tenantów. Ustaw skończony limit właścicielstwa w ustawieniach deploymentu, zapewnij i monitoruj wspólny storage oraz uwzględnij historię przebiegów. Platforma obecnie nie egzekwuje globalnego budżetu storage. ## Modele AI — konfigurowane w aplikacji, nie tutaj { #ai-models-configured-in-the-app-not-here } Modele czatu nie są zmiennymi środowiskowymi. Każda organizacja trzyma własne klucze providerów w vaulcie (Settings → Models), a spec każdego agenta nazywa profil modelu, na którym ten agent działa. `AI_MODEL`, `AI_TEMPERATURE`, `AI_THINKING_ENABLED`, `AI_THINKING_EFFORT`, `AI_AVAILABLE_MODELS`, `AI_FRAMEWORK` i `LLM_PROVIDER` zostały usunięte razem z ogólnym asystentem z szablonu; ustawienie ich teraz nic nie robi. Jedynym poświadczeniem modelu, które zostaje w środowisku, jest klucz do embeddingów — zobacz RAG poniżej. ## Obserwowalność (Logfire) { #observability-logfire } Opcjonalna i wyłączona, dopóki nie ustawisz tokena. Nic w tej sekcji nie musi być skonfigurowane: bez `LOGFIRE_TOKEN` platforma działa w pełni, a run wciąż zapisuje lokalnie swoje trace id. Token kupuje same trace'y, a trace jest kopią runu — wiadomością użytkownika, wyjściem modelu i każdym argumentem oraz wynikiem narzędzia — bo zapisywanie dokładnie tego jest zadaniem backendu obserwowalności. Ustawienie tokena jest więc decyzją o tym, dokąd może trafić treść runów, a nie tylko o dashboardach. Wdrożenie nad danymi medycznymi, prawnymi lub HR najpierw czyta, [co opuszcza maszynę](data-protection.md#traces). Trzy rzeczy mogą skierować runy do projektu i nakładają się na siebie: - `LOGFIRE_TOKEN` stąd instrumentuje Pydantic AI globalnie, więc każdy run trafia do projektu samego wdrożenia. Konfigurują go dwa procesy: API przy starcie (`app/main.py`) oraz worker, raz przy starcie (`app/worker/__main__.py`), bo nigdy nie wykonuje lifespanu API. Spany workera niosą `-worker`, więc w jednym projekcie da się odróżnić wolny run zaplanowany od wolnej tury czatu. - [Środowisko](environments.md#tracing-per-environment) może nieść własny token zapisu, zapieczętowany w vaulcie, który przekierowuje związane z nim runy. - Blok [`observability`](reference/spec.md#observability) agenta nazywa projekt jego własny — zwykle klienta. Tryb `content` agenta decyduje, ile niesie każdy span: `full`, domyślny, zapisuje wiadomość, wyjście i każde wywołanie narzędzia; `none` zapisuje tylko czas, tokeny, koszt i nazwy narzędzi. Jest stosowany tam, gdzie agent jest instrumentowany, więc trzyma niezależnie od tego, który token trace'uje run — przy tym na poziomie wdrożenia agent zostaje przypięty do instrumentacji bez treści, a specjalista, do którego agent deleguje — napisany inline albo wymyślony w trakcie runu przez model — dziedziczy tryb. Jedno ograniczenie, zanim na tym polegasz: podpięcie tej instrumentacji jest best-effort, a błąd jest logowany, gdy run trwa dalej. Logi aplikacji z API, workera, procesów ingestujących i CLI trafiają na stderr ze znacznikiem czasu, poziomem i nazwą loggera. Działają bez tokenu Logfire i są widoczne przez `docker compose logs -f`. `LOG_LEVEL` domyślnie wynosi `INFO`; ustaw `DEBUG`, `WARNING`, `ERROR` lub `CRITICAL`, aby zmienić szczegółowość. Redakcja danych osobowych i poświadczeń obejmuje także tracebacki wyjątków. Logowanie zapytań SQL pozostaje wyłączone, dopóki nie ustawisz `DB_ECHO=true`. Komunikaty INFO/DEBUG transportu HTTP są wyciszone. Spany Logfire domyślnie nie trafiają na konsolę w żadnym środowisku; ustaw `LOGFIRE_CONSOLE=true`, aby je wypisywać podczas debugowania. Nie zmienia to eksportu trace'ów do Logfire. Bez tokenu spany nie są eksportowane; `agenticos cmd doctor` zgłasza to w linii `tracing`. Daj każdemu wdrożeniu własne `LOGFIRE_ENVIRONMENT`, na przykład `local` na laptopie i `production` na serwerze. Jeden projekt może wtedy trzymać oba i nadal je rozróżniać. | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `LOG_LEVEL` | `INFO` | Poziom logów aplikacji: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` | | `LOGFIRE_CONSOLE` | `false` | Wypisuj spany Logfire na konsolę podczas debugowania | | `LOGFIRE_TOKEN` | (none) | Token Pydantic Logfire. Zdobądź go na https://logfire.pydantic.dev | | `LOGFIRE_SERVICE_NAME` | `agenticos` | Nazwa usługi na dashboardzie Logfire | | `LOGFIRE_ENVIRONMENT` | `development` | Etykieta środowiska | | `LOGFIRE_ORGANIZATION` | (none) | Slug organizacji, do zbudowania linku **do** zapisanego trace'u. Token jest poświadczeniem do _zapisu_ i nie niesie żadnego z tych slugów | | `LOGFIRE_PROJECT` | (none) | Slug projektu, obok organizacji. Gdy któregokolwiek brakuje, `logfire_trace_id` runa nadal jest zapisywane, a link nie jest oferowany | | `LOGFIRE_BASE_URL` | `https://logfire-us.pydantic.dev` | Do którego wdrożenia Logfire te slugi należą. `logfire-eu` to inny host, a link zbudowany dla niewłaściwego zwraca 404 | ## Wyszukiwanie w sieci { #web-search } | Zmienna | Domyślnie | Opis | | --- | --- | --- | ## RAG (Retrieval Augmented Generation) { #rag-retrieval-augmented-generation } ### Baza wektorowa { #vector-database } pgvector korzysta z istniejącego połączenia do PostgreSQL. Nie trzeba nic dodatkowo konfigurować — ale **obraz** musi być `pgvector/pgvector:pg16`, co przypina tutaj każdy plik compose. !!! note "\"Vector store: unconfigured\" na świeżym wdrożeniu to nie usterka" Rozszerzenie jest tworzone przy pierwszym zapisie do kolekcji, więc przed pierwszym dokumentem naprawdę go nie ma i mówią o tym zarówno strona System w panelu administracyjnym, jak i `agenticos cmd doctor`. Rozwiązuje się samo przy pierwszej ingestii. Usterką jest tam `unhealthy`, a komunikat nazywa, która z trzech przyczyn: obraz nie dostarcza pgvectora; łącząca się rola nie może go utworzyć; albo katalog danych nosi wiersz rozszerzenia, podczas gdy obraz, na którym teraz działa, stracił bibliotekę. Wszystkie trzy zawodzą upload już po przyjęciu bajtów i wszystkie trzy czytały się kiedyś tak samo jak zdrowy pierwszy dzień ([#1504](https://github.com/vstorm-co/agenticos/issues/1504)). ### Embeddingi { #embeddings } Nic tutaj. Każda kolekcja sama wskazuje providera, przez którego embeduje, model, oraz albo klucz w vaulcie organizacji, który za to płaci, albo — dla bezkluczowego providera `ollama` — **usługę lokalną**, wiersz w Knowledge → Integrations mówiący, gdzie odpowiada Ollama wdrożenia albo organizacji. Zobacz [Przetwarzanie plików](file-processing.md#embeddings-the-model-whose-endpoint-answers-and-whose-key-pays). Były tu kiedyś dwie zmienne. `EMBEDDING_MODEL` preselekcjonowała model dla nowych kolekcji i została usunięta: formularz oferuje modele, które serwuje wybrany provider. `EMBEDDING_OLLAMA_BASE_URL` wskazywała jedną Ollamę dla całego wdrożenia, a teraz jest usługą lokalną, per organizacja albo ogólnowdrożeniową. ### Parsowanie dokumentów — konfigurowane per kolekcja, nie tutaj { #document-parsing-configured-per-collection-not-here } Parser, OCR, rozmiar chunka, zakładka między chunkami, strategia chunkowania i model opisujący obrazy **nie** są zmiennymi środowiskowymi. Są zapisane na każdej knowledge base (`knowledge_bases.ingestion_config`) i edytowane na `/rag`, a każde z nich można dodatkowo nadpisać dla pojedynczego uploadu. Powodem jest to, że jedna wartość obowiązująca w całej instalacji sprawiała, że ten sam formularz dawał różne kolekcje na dwóch wdrożeniach i nic w produkcie nie pokazywało która — a zeskanowane archiwum umów i folder notatek w Markdownie chcą różnych odpowiedzi na tym samym wdrożeniu. `PDF_PARSER`, `CHAT_PDF_PARSER`, `LLAMAPARSE_TIER`, `LITEPARSE_OCR_LANGUAGE`, `LITEPARSE_TIMEOUT_SECONDS`, `RAG_ENABLE_OCR`, `RAG_CHUNK_SIZE`, `RAG_CHUNK_OVERLAP` i `RAG_CHUNKING_STRATEGY` zostały usunięte; ustawienie ich teraz nic nie robi. Dwie rzeczy, które kiedyś tu zostawały, są teraz wierszami w produkcie. Klucz LlamaParse to wpis w vaulcie, wskazywany przez konfigurację ingestii kolekcji (`llamaparse_secret_id`), a kolekcja na LlamaParse bez niego jest odrzucana już przy formularzu — `LLAMAPARSE_API_KEY` zniknęła. Serwer OCR, do którego LiteParse wysyła strony, to usługa lokalna rodzaju `ocr` w Knowledge → Integrations, wybierana per kolekcja (`ocr_endpoint_id`), zarejestrowana przez operatora organizacji albo — dla wszystkich organizacji — przez administratora wdrożenia; `LITEPARSE_OCR_SERVER_URL` też zniknęła. Żadna z nich nie była widoczna dla tenanta, o którego dokumentach decydowała. Załączniki w czacie są czytane PyMuPDF-em i nie są konfigurowalne: załącznik nie należy do żadnej kolekcji, więc nie ma zapisanej konfiguracji do odczytania. ### Synchronizacja Google Drive { #google-drive-sync } | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `GOOGLE_DRIVE_CREDENTIALS_FILE` | `credentials/google-drive-sa.json` | Ścieżka do poświadczeń konta serwisowego Google, wyłącznie dla `rag-sync-gdrive` | **To poświadczenie CLI, a nie fallback dla źródła synchronizacji.** Źródło synchronizacji `gdrive` nazywa sekret `gcp_service_account` w vaulcie swojej organizacji i działa na nim albo nie działa wcale: klucz obowiązujący w całym wdrożeniu, zastępujący brakujący, oznaczał, że `folder_id` tenanta wybierał spośród tego, co widniało pod kontem serwisowym operatora. Poświadczenie źródła nie jest ustawieniem ani polem konfiguracji — zobacz [Sekrety i vault](secrets.md). Plik to klucz konta serwisowego: [konsola Cloud](https://console.cloud.google.com/iam-admin/serviceaccounts) → create a service account → Keys → Add key → JSON. Następnie **udostępnij folder na Drive własnemu adresowi e-mail konta serwisowego** — to taki sam principal jak każdy inny, a folder, którego nikt mu nie udostępnił, listuje się jako pusty, a nie jako odrzucony. ## Wgrane pliki w spoczynku { #uploaded-files-at-rest } Gdzie leżą załączniki z czatu, avatary, obrazy brandingu i oryginały dokumentów bazy wiedzy. Jeden backend na wdrożenie, nigdy per organizacja, a jego zmiana nie przenosi tego, co trzyma już ten drugi. | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `FILE_STORAGE_BACKEND` | `local` | `local` zapisuje pod `MEDIA_DIR`; `s3` zapisuje do bucketu zgodnego z S3 | | `FILE_STORAGE_S3_BUCKET` | (puste) | Bucket. Wymagany przy backendzie `s3`; API odmawia rozpoczęcia uploadu bez niego | | `FILE_STORAGE_S3_ENDPOINT` | (brak) | Puste dla AWS. Adres usługi dla MinIO lub innego zgodnego magazynu | | `FILE_STORAGE_S3_REGION` | `us-east-1` | Region AWS | | `FILE_STORAGE_S3_ACCESS_KEY` | (puste) | Na AWS zostaw **puste**: odpowiada wtedy własny łańcuch poświadczeń boto3, czyli profil instancji albo rola IRSA, a nie długowieczny klucz w pliku środowiskowym | | `FILE_STORAGE_S3_SECRET_KEY` | (puste) | Druga połowa, tak samo | | `FILE_STORAGE_S3_PATH_STYLE` | `false` | `true` dla MinIO i większości zgodnych magazynów, które adresują bucket ścieżką. Żądanie w stylu virtual-host trafia u nich na błąd DNS, a nie na S3 | | `FILE_STORAGE_S3_PREFIX` | (puste) | Każdy klucz zapisywany przez to wdrożenie leży pod nim, więc jeden bucket może pomieścić więcej niż jedno wdrożenie bez mieszania kluczy | | `FILE_STORAGE_S3_ENCRYPTION` | `sse-s3` | O co magazyn jest proszony przy każdym zapisie: `sse-s3` (własny klucz bucketu), `sse-kms` (klucz poniżej) albo `none` | | `FILE_STORAGE_S3_KMS_KEY_ID` | (brak) | Identyfikator albo ARN klucza KMS. **Wymagany** przy trybie `sse-kms`: S3 czyta nienazwany `aws:kms` jako własny klucz zarządzany przez AWS `aws/s3`, a nie jako domyślny klucz bucketu, więc puste oznaczałoby szyfrowanie kluczem, którego nikt nie wybrał | !!! warning "`none` jest dla magazynu bez KMS i nie jest szyfrowaniem" MinIO odmawia SSE-S3, dopóki nie skonfigurowano serwera KES, więc zgodny magazyn bez KMS potrzebuje `none`, żeby w ogóle działać — a wdrożenie pracujące w ten sposób ma tyle szyfrowania, ile dają mu wolumeny, i nic ponad to. `agenticos cmd doctor` raportuje taką konfigurację jako nieskonfigurowaną, a nie zdrową, i to samo mówi wiersz o spoczynku w [bezpieczeństwie](security.md#what-is-encrypted-where). Bucket, do którego zapisuje wdrożenie, potrzebuje `s3:PutObject`, `s3:GetObject`, `s3:DeleteObject` i `s3:ListBucket`, a przy trybie `sse-kms` dodatkowo `kms:Encrypt`, `kms:Decrypt` i `kms:GenerateDataKey` na kluczu. Lokalne MinIO uruchamia `make docker-minio` na `:9000` z `minioadmin` / `minioadmin`. ### Wygenerowane media { #generated-media } Obrazy, mowa i wideo generowane przez agentów są trzymane w tym samym magazynie, pod `generated_/`, a każde jest zapisane przy swojej organizacji i konwersacji, w której powstało. To, co organizacja przechowuje, jest ograniczone podwójnie. Zobacz [Ochronę danych](data-protection.md#generated-media), żeby sprawdzić, co i kiedy jest usuwane. | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `GENERATED_MEDIA_QUOTA_MB` | `5120` | Ile wygenerowanych mediów może trzymać jedna organizacja, w MiB. Liczy się każdy plik, także utworzony bez rozmowy - z podglądu lub przebiegu testowego, z wtrącenia `/btw` w kanale, z runu API, który nie wskazuje rozmowy; sweep usuwa takie pliki po 7 dniach. Sprawdzane przed każdym płatnym wywołaniem generowania: po jego przekroczeniu narzędzie odpowiada, że organizacja wykorzystała swój magazyn, i niczego nie wydaje. Wpuszczone wywołanie rezerwuje w limicie szacowany rozmiar swojego pliku, dopóki plik nie zostanie zapisany - 16 MiB na obraz, 8 KiB na znak mowy, 2 MiB na sekundę wideo - więc wywołanie, którego szacunek przekroczyłby limit organizacji, zostaje odrzucone, a równoległe przebiegi albo równoległe wywołania w jednej odpowiedzi nie mogą przejść wszystkie na tej samej liczbie. Plik większy niż szacunek liczy się według prawdziwego rozmiaru. `0` to brak limitu | | `GENERATED_MEDIA_RETENTION_DAYS` | `90` | Wiek, w którym codzienne zadanie `sweep-generated-media` usuwa wygenerowany plik i jego zapis, bez względu na to, czy jego konwersacja wciąż istnieje. `0` trzyma je, aż ich konwersacja albo organizacja zostanie usunięta. Plik bez konwersacji odchodzi po 7 dniach w każdym przypadku | Plik jest serwowany strumieniowo, tylko w zakresie bajtów, o który prosi odtwarzacz - z dysku albo przez `GetObject` z zakresem - więc długi klip nigdy nie jest trzymany w całości ani w API, ani na serwerze konsoli. ### Synchronizacja S3/MinIO { #s3minio-sync } | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `S3_RAG_ENDPOINT` | (none) | URL endpointu S3/MinIO. Źródło synchronizacji może go nadpisać | | `S3_RAG_ACCESS_KEY` | (empty) | Access key, wyłącznie dla polecenia CLI `rag-sync-s3` | | `S3_RAG_SECRET_KEY` | (empty) | Secret key, tak samo | | `S3_RAG_BUCKET` | `agenticos-rag` | Nazwa bucketa | | `S3_RAG_REGION` | `us-east-1` | Region AWS. Własny region poświadczenia wygrywa tam, gdzie je ma | To jest _konektor synchronizacji_, który czyta bucket należący do tenanta — a nie magazyn plików samego wdrożenia opisany wyżej. Konfiguruje się je osobno i jest to świadome: jedno jest infrastrukturą, drugie danymi tenanta. **Para kluczy tutaj należy do CLI, a nie do źródła synchronizacji.** Źródło synchronizacji `s3` nazywa sekret `aws_credentials` w vaulcie swojej organizacji, tak samo jak źródło `gdrive` nazywa konto serwisowe. Endpoint i region nadal cofają się do tych ustawień, bo żadne z nich nie nazywa principala — mówią, gdzie jest magazyn, a nie kto pyta. ## Opublikowane aplikacje { #published-artifacts } Strony, które agenci publikują za pomocą capability `artifacts`. Ich bajty leżą w opisanym wyżej magazynie plików; te zmienne je ograniczają i mówią, skąd są serwowane. Zobacz [Aplikacje](artifacts.md). | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `ARTIFACT_MAX_BYTES` | 5 MiB | Jedna wersja jednej strony. Publikacja powyżej tej wartości jest odrzucana z komunikatem, który czyta model | | `ARTIFACT_MAX_VERSIONS` | `20` | Liczba wersji przechowywanych na aplikację. Najstarsza jest usuwana, gdy pojawia się nowsza, chyba że publiczny link jest do niej przypięty | | `ARTIFACT_VIEW_TTL_SECONDS` | `300` | Jak długo otwiera się podpisany adres treści, najwyżej 3600. Także jak długo otwarta strona przeżywa odwołany grant albo link | | `ARTIFACT_PUBLIC_DATA_CACHE_BYTES` | 32 MiB | Najwięcej bajtów rekordów, jakie jeden proces API trzyma przez 30 sekund dla [publicznych linków i embedów stron](artifacts.md#the-cache-and-the-cap). Tylko w pamięci procesu, nigdy w Redisie. Najdawniej używane odczyty odpadają pierwsze, a odczyt ponad budżet nie jest zachowywany. Co najmniej 1024 | | `ARTIFACT_ORIGIN` | (puste) | Skąd serwowana jest treść - strony, ich [zestaw bibliotek](artifacts.md#the-library-set) i dokument do osadzania. Puste serwuje ją z `PUBLIC_BASE_URL`, izolowaną polityką `sandbox`. Ustaw na host w osobnej domenie rejestrowalnej, skierowany do tego API, żeby dodatkowo umieścić stronę w innej witrynie | | `ARTIFACT_RENDER_CDP_URL` | (puste) | Endpoint Chromium DevTools, w którym [`preview_artifact`](artifacts.md#seeing-the-page-preview) rysuje strony, np. `http://artifact-render:9222`. To wyłącznie konfiguracja wdrożenia: nie jest sprawdzana względem `BROWSER_CDP_ALLOWED_HOSTS`, która wymienia, czym mogą sterować autorzy agentów, a agent przeglądający strony dostaje odmowę dla tego hosta, nawet gdy ta lista go wymienia. Backend potrzebuje dodatku `browser`. Gdy jest puste, podgląd zwraca raport bez obrazu | | `VISUAL_MAP_TILES` | (puste) | Jedyny serwer kafelków, na którym [wizualizacja](artifacts.md#visuals) albo aplikacja może narysować mapę: `openstreetmap`, `usgs-topo` albo szablon URL `https://` z `{z}`, `{x}` i `{y}` po razie. Deployment pobiera każdy kafelek i serwuje go stronom z własnej ścieżki kafelków; żadna strona nie rozmawia z serwerem. Bez tego ustawienia nowe mapy używają lokalnych konturów krajów w SVG, pinezek i połączeń. Istniejące strony tras mogą pokazywać zarys przystanków. Wartość, której nie da się serwować, jest odrzucana przy starcie | | `VISUAL_MAP_TILES_ATTRIBUTION` | (puste) | Atrybucja pokazywana pod mapą na własnym serwerze kafelków, np. `© OpenStreetMap contributors`. Wymagana przy szablonie URL; preset ma własną | **Kafelki map pobiera deployment, nie przeglądarka czytelnika.** Strona prosi o `/api/v1/artifact-content/tiles/{z}/{x}/{y}`; API sprawdza liczby, pobiera kafelek z serwera, trzyma ostatnie 2048 w pamięci i odpowiada, więc klucz z szablonu nigdy nie opuszcza serwera. Trasa nie wymaga sesji - ładuje ją każda strona - więc ma limit na adres (`RATE_LIMIT_MAP_TILES_PER_MINUTE`). Pilnuj warunków dostawcy: publiczne kafelki OpenStreetMap są do lekkiego użytku, a ruchliwy deployment powinien mieć własny serwer albo płatnego dostawcę. _Admin → System_ pokazuje, czy serwer odpowiada. **`ARTIFACT_ORIGIN` jest czytane dwa razy i obie strony muszą je widzieć.** Backend podpisuje na nim adresy treści, a frontend dodaje jego ścieżkę treści, `/api/v1/artifact-content/`, do `frame-src` konsoli. Ustaw je w środowisku backendu i w środowisku frontendu; pliki compose przekazują je obu. Wartość tylko w jednym z nich daje pustą ramkę, bo przeglądarka odmawia załadowania strony z originu, na który konsola nie pozwoliła. **Gdy `ARTIFACT_ORIGIN` jest puste, `PUBLIC_API_URL` frontendu musi być dokładnie równe `PUBLIC_BASE_URL` backendu, łącznie ze ścieżką.** Backend podpisuje wtedy adresy treści na `PUBLIC_BASE_URL`, a `frame-src` konsoli wskazuje trasę treści pod `PUBLIC_API_URL`, więc `https://example.com/agenticos` po jednej stronie i `https://example.com` po drugiej to ta sama pusta ramka. **Renderer to osobna przeglądarka, bez sieci.** Compose dostarcza go jako opcjonalny profil `artifact-render`: przypięty bezgłowy Chromium w sieci wewnętrznej, do której dołączają tylko API i worker. Na każde żądanie, które wysyła render, odpowiada API - stroną, jej plikami bibliotek i dokumentem hosta - albo je odrzuca, więc przeglądarka nie potrzebuje drogi na zewnątrz. A ponieważ renderuje rekordy ludzi, nie może być przeglądarką, którą steruje [agent przeglądający strony](reference/capabilities.md#browser-automation). Uruchom go przez `docker compose --profile artifact-render up -d`, a potem ustaw `ARTIFACT_RENDER_CDP_URL=http://artifact-render:9222`. Nie dodawaj `artifact-render` do `BROWSER_CDP_ALLOWED_HOSTS`: ta lista jest dla agentów przeglądających strony, a renderer nie potrzebuje w niej wpisu. Jeden proces rysuje naraz dwie strony, każdą w ciągu 15 sekund. ## Datasety { #datasets } Przypadki testowe, na których działają evale. Zobacz [Datasety](datasets.md). | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `DATASET_MAX_VERSION_ROWS_PER_ORG` | `5000000` | Ile rewizji przypadków mogą łącznie przechowywać datasety jednej organizacji. Każdy dodany przypadek i każda edycja to rewizja, przechowywana dla wersji, które ją zawierają; wersja zapisuje tylko to, co zmieniła, więc dataset z 10 000 przypadków edytowany 500 razy przechowuje 10 500, a nie 5 milionów. Dodanie albo edycja ponad limit jest odrzucana z podaniem, ile jest przechowywane; usunięcie przypadku nic nie zapisuje i jest zawsze dozwolone, a usunięcie niepotrzebnych datasetów zwalnia ich rewizje. Nazwa jest starsza niż to znaczenie: liczyła każdy przypadek każdej wersji, dopóki wersje kopiowały swoje przypadki. Limit jest przybliżony: każda zmiana jest sprawdzana osobno, więc zmiany w różnych datasetach w tej samej chwili mogą go nieznacznie przekroczyć. Chroni miejsce w bazie, nie służy do rozliczeń | ## Workspace'y agentów { #agent-workspaces } Workspace `state` nie potrzebuje tutaj niczego. Jest trzymany w tej bazie danych, działa na każdym wdrożeniu i jest tym, co agent dostaje domyślnie — więc ustawienia poniżej dotyczą wyłącznie workspace'u opartego na kontenerze. | Zmienna | Domyślnie | Uwagi | | --- | --- | --- | | `SANDBOX_STATE_MAX_BYTES` | 32 MiB | Na **przechowywany** workspace. Po jego przekroczeniu zapis zostaje odrzucony komunikatem, który czyta model | | `SANDBOX_INLINE_IMAGE_MAX_BYTES` | 5 MiB | Powyżej tej wartości załączony obraz jest zapisywany do workspace'u i nie jest dodatkowo wysyłany inline | **Procent w czacie to dwa różne sufity i mówi, który.** Przechowywany workspace zapełnia się względem `SANDBOX_STATE_MAX_BYTES` powyżej — bajty, a ich wyczerpanie _odrzuca zapis_. Kontener raportuje rezydentną **pamięć** względem sufitu, który jego host ustawił dla danego runtime'u, czyli `1g`, o ile allowlista nie mówi inaczej, a wyczerpanie jej to zabicie przez OOM, a nie odmowa. Dlatego pasek mówi `workspace 12% full` dla pierwszego i `sandbox memory 12% full` dla drugiego; raportowanie jednego jako drugiego nazywałoby limit, który nie obowiązuje. **To, gdzie działają sandboksy, nie jest ustawieniem.** To wiersz na organizację — Sandboxes w aplikacji, `sandbox_connections` w bazie danych — z tokenem usługi w vaulcie. Dwa powody, i żadnego z nich nie da się wyrazić zmienną środowiskową: wdrożenie może trzymać więcej niż jeden host, a jeden adres na wdrożenie dawał każdej organizacji ten sam; oraz token autoryzuje otwarcie sesji, która uruchamia polecenia na hoście trzymającym socket Dockera, więc należy tam, gdzie mieszka każde inne poświadczenie w spoczynku. Operator rejestruje połączenie z nazwą, adresem i kluczem z vaultu. Agent nazywa jedno po id, dokładnie tak, jak nazywa profil modelu, albo nie nazywa żadnego i bierze domyślne dla organizacji — więc przeniesienie na inny host to jedna edycja, a nie republikacja każdego agenta. **Token usługi jest wart tyle, ile socket Dockera.** Usługa trzyma ten socket, socket jest nieuwierzytelnionym API dla roota na hoście, a token jest tym, co otwiera na nim sesję. Nigdy w przeglądarce, nigdy w logu, nigdy zacommitowany — dlatego ekran operatora pokazuje tylko to, że poświadczenie jest podpięte, i dlatego `GET /policy` idzie przez to API, a nie jest pobierane przez przeglądarkę. Własny dashboard usługi (`SANDBOXD_UI_ENABLED`) jest z tego samego powodu wyłączony w każdym dostarczanym pliku compose: prosi człowieka, żeby wkleił tę wartość do przeglądarki. `SANDBOXD_TOKEN` w `backend/.env` to token _samej usługi_ — to, co zaakceptuje demon z pliku compose. `make sandbox-token` go generuje, a formularz połączenia zapisuje tę samą wartość w vaulcie za ciebie. API czyta to ustawienie dokładnie w jednym celu: żeby zaproponować je vaultowi. Proszenie kogoś, żeby skopiował sekret z pliku, który jego własny stack i tak już czyta, to tarcie bez niczego za nim. **Nigdy** nie jest używany do sięgnięcia hosta — rozwiązanie połączenia odpieczętowuje wpis w vaulcie, który to połączenie nazywa, i to pozostaje jedyną drogą. Wdrożenie, które zostawi go nieustawionym, traci jeden przycisk i nic więcej, i wkleja token ręcznie. Ten sam formularz pyta, czy jakaś usługa już odpowiada, zamiast wymagać od operatora wiedzy, że usługa sandbox z `make dev` mieszka pod `http://sandboxd:8080`. Ten adres nie jest konfiguracją i to celowo — jest wierszem, bo wdrożenie może trzymać kilka hostów — więc API sonduje nieuwierzytelniony `/healthz` pod adresem, którego używa plik compose tego projektu, i wstępnie wypełnia to, co odpowiedziało. Nic nie jest rozstrzygane samym pytaniem: brak usługi to puste pole, a połączenie już tam wskazujące jest nazwane, żeby nikt nie zarejestrował jednego hosta dwa razy. **Adres jest pobierany przez to API, więc jest jako adres walidowany.** Zarejestrowanie albo sondowanie połączenia każe kontenerowi API wysłać uwierzytelniony `GET` i oddaje z powrotem treść JSON, co jest prymitywem do request forgery, jeśli adres bierze się na wiarę. Dlatego `base_url` odrzuca wszystko, co nie jest `http(s)` z hostem, i odrzuca wprost adresy link-local oraz nazwy hostów metadanych instancji — `169.254.169.254` i `metadata.google.internal` nigdy nie są usługą sandboksa. Adresy prywatne pozostają dozwolone i muszą: `http://sandboxd:8080` wewnątrz compose i `http://localhost:8080` dla developera uruchamiającego API na swoim hoście są prywatne, więc denylista zakresów prywatnych odrzuciłaby wdrożenie, które opisuje ta strona. Znaczy to, że walidator zwęża dziurę, zamiast ją zamykać — nazwa hosta, która rozwiązuje się na coś wewnętrznego, nadal się rozwiąże. **Granicą, która naprawdę trzyma, jest `connections:manage` plus polityka egress na kontenerze API**: komu wolno zarejestrować host, temu się ufa, że może, a wdrożenie w sieci trzymającej nieuwierzytelnione wewnętrzne API powinno powiedzieć to na poziomie sieci, a nie tutaj. ### O jakie środowiska agent może poprosić { #which-environments-an-agent-may-ask-for } Dostarczane są dwa runtime'y. `workbench` (1,93 GB) ma Pythona 3.12, Node 24, LibreOffice i biblioteki, których agent potrzebuje, żeby czytać, zapisywać, konwertować i wykreślać pliki, o których jest rozmowa, w tym liteparse z OCR. `desktop` to pulpit Linux, XFCE i Firefox, do obsługi komputera. Oba są zdefiniowane w `backend/app/core/catalog/sandbox_runtimes.json`. Dodanie kolejnego to edycja tam plus `make sandbox-runtimes`, które wpisuje `SANDBOXD_RUNTIMES` do wszystkich trzech plików compose; ta zmienna jest jedynym kanałem, którym usługa przyjmuje runtime'y, a `PUT /policy` celowo odrzuca zmianę składu tej listy. `sandbox.md#which-environments-an-agent-may-ask-for` opisuje format pole po polu, trzy pułapki (pierwszy wpis jest domyślny, `network_mode` nie jest dziedziczony, za build płaci się przy starcie przez `prewarm`) i dlaczego wygenerowana kopia w plikach compose nie może odjechać od katalogu. ### Własne ustawienia usługi { #the-services-own-settings } Każde pole konfiguracji usługi to `SANDBOXD_` plus jego nazwa, więc to podzbiór, a nie słownik. Oto te, które ustawiają dostarczane pliki compose albo które decydują o tym, czy pliki przetrwają: | Zmienna | W dostarczanej konfiguracji | O czym decyduje | | --- | --- | --- | | `SANDBOXD_WORKSPACE_ROOT` | ścieżka na hoście | Gdzie mieszka katalog roboczy każdej sesji, bind-mountowany z _hosta_. **Nieustawione — pliki istnieją wyłącznie wewnątrz działającego kontenera**: zebranie bezczynnej sesji je wyrzuca, a następne żądanie otwiera pusty workspace, bez śladu w logu. To też jest to, co umożliwia przeglądanie: odczyt workspace'u nigdy nie uruchamia kontenera | | `SANDBOXD_SANDBOX_UID` | `10001` | Nieuprzywilejowany użytkownik, jako który działa sandbox, zamiast roota — ucieczka z kontenera zaczyna się od tego, jako kto kontener działa, a każdy plik zapisany przez agenta należy na hoście do tego uid. **Musi być własnym uid usługi**: otwarcie sesji robi `chown` workspace'u na tego użytkownika, co nieuprzywilejowana usługa może zrobić tylko dla siebie. Dotyczy runtime'u, który wdrożenie _buduje_, bo gotowy obraz nie ma takiego konta, a agent w środku nie mógłby niczego zainstalować | | `SANDBOXD_CONTAINER_TTL` | 86400s | Jak długo trzymany jest _zatrzymany_ utrwalony kontener. Odzyskuje to, co sesja zainstalowała — build, wheele, `node_modules` — i zostawia workspace nietknięty, bo to pliki są pracą. Nieustawione — trzymane na zawsze | | `SANDBOXD_PERSIST_CONTAINERS` | `true` | Kontener zamkniętej sesji jest zachowywany, a nie usuwany, więc kolejna sesja tego workspace'u startuje bez builda. Kosztuje jeden zatrzymany kontener na workspace; `SANDBOXD_CONTAINER_TTL` to ogranicza | | `SANDBOXD_MAX_SESSIONS_PER_TENANT` | `5` | Jedna organizacja nie może zabrać całej puli. `SANDBOXD_MAX_SESSIONS` (20) to pula | | `SANDBOXD_NETWORK_MODE` | `none` | Domyślna sieć dla sandboksa. `none` to brak sieci w ogóle; runtime może nazwać dla siebie `bridge` | | `SANDBOXD_UI_ENABLED` | `0` | Własny dashboard usługi. Wyłączony, bo prosi człowieka o wklejenie do przeglądarki tokena równoważnego rootowi | | `SANDBOXD_IDLE_TIMEOUT` | 1800s | Jak długo żyje bezczynna sesja, zanim zostanie zamknięta i zebrana | | `SANDBOXD_MEM_LIMIT` | `1g` | Domyślny sufit pamięci, a więc liczba, której udziałem jest procent `sandbox memory` w czacie | ### Uruchamianie usługi na innym hoście { #running-the-service-on-another-host } Nic w połączeniu nie zakłada lokalnego adresu — to wiersz trzymający URL i poświadczenie z vaultu, a formularz sonduje to, co dostanie. Host gdzie indziej potrzebuje trzech rzeczy i żadnego kodu: 1. **Socketa Dockera**, bo usługa startuje kontenery. To root na tamtej maszynie, dlatego token poniżej jest wart tyle, ile jest. 2. **`SANDBOXD_WORKSPACE_ROOT` na prawdziwym dysku, zamontowany pod tą samą ścieżką po obu stronach.** Usługa tworzy katalog, a potem prosi _demona_ o bind-mount, a demon rozwiązuje ścieżkę na hoście — więc nazwany wolumen albo ścieżka istniejąca wyłącznie wewnątrz kontenera usługi zostaje odrzucona z `mounts denied`. 3. **TLS i token, którego nikt nie współdzieli.** Wewnątrz compose adres to `http://sandboxd:8080` w prywatnej sieci; przez internet to usługa, która uruchomi polecenia dla każdego, kto trzyma token, więc należy ją schować za HTTPS z własną wartością. Potem zarejestruj go w Sandboxes jak każdy inny i wskaż na niego agenta po nazwie. Usługa z compose to jedno wdrożenie tego samego obrazu. ### Kiedy sesja jest otwarta { #when-a-session-is-open } Zakładka **Running** listuje sesje, które trzyma usługa, odświeżana co dziesięć sekund, a sesja to jeden workspace na jednym hoście. Trzy stany, z czego pojawiają się tylko dwa pierwsze: - **running** — kontener istnieje i jest rezydentny. Otwierany przez pierwsze wywołanie narzędzia przez agenta w rozmowie, a nie wtedy, gdy rozmowa się zaczyna. - **hibernated** — wiersz istnieje, a kontener nie. Sesja bezczynna dłużej niż `SANDBOXD_EVICT_IDLE_AFTER` jest hibernowana, żeby zwolnić slot, a jej następne żądanie ją budzi. Wymaga to `WORKSPACE_ROOT`, bo inaczej obudzenie otworzyłoby pusty workspace, więc usługa odrzuca tę kombinację, zamiast tak zrobić. - **gone** — po `SANDBOXD_IDLE_TIMEOUT` sesja jest zamykana i zbierana. Przy `PERSIST_CONTAINERS` kontener to przeżywa, więc następna sesja tego samego workspace'u startuje bez builda. Pusta zakładka Running znaczy więc, że żaden agent ostatnio nie używał shella, a nie że nic nie jest skonfigurowane — a workspace z plikami i bez sesji to normalny stan spoczynku. Usługa działa za profilem compose `sandbox`, który jest domyślnie włączony w lokalnym devie i wyłączony gdzie indziej, dopóki operator się na niego nie zdecyduje — montowanie socketu Dockera na współdzielonym hoście jest aktem świadomym. `COMPOSE_DEV_PROFILES` w Makefile to jedyne miejsce, gdzie się to zmienia. `uv run agenticos cmd doctor` sonduje każde zarejestrowane połączenie: czy odpowiada, czy przyjmuje swoje poświadczenie i czy w ogóle dopuszcza jakikolwiek runtime. Brak zarejestrowanego połączenia to ostrzeżenie, a nie błąd — workspace `state` nie potrzebuje żadnego. **Przeglądanie tego, co agenci zachowali.** Workspaces to osobny ekran — nie część Sandboxes, które są o _hostach_. Każdy wiersz nazywa agenta, rozmowę, do której należą pliki (albo ile czatów do nich sięga, dla workspace'u, którego nie posiada żadna pojedyncza rozmowa), kto może je widzieć, jak jest duży i kiedy był ostatnio używany. **Open** prowadzi na własną stronę tego workspace'u, w kształcie, którego używa edytor skilli: drzewo po lewej — foldery przechodzone po jednym, z polem wyszukiwania obejmującym całe drzewo, a nie folder na ekranie — i sam plik wyrenderowany obok. Czytanie trzech plików to więc trzy kliknięcia, a lista nigdy się nie zamyka. Pobieranie jest przy wierszu, a nie obok czytnika, bo wybranie pliku go odczytuje, a duże archiwum to coś, czego kopii ktoś chce bez płacenia za to. Drugi widok na liście spłaszcza każdy plik, który czytelnik może zobaczyć, w jedną siatkę — odpowiedź na pytanie „kto trzyma kopię tego CSV”, którego strona pojedynczego workspace'u udzielić nie potrafi. **Kliknięcie pliku otwiera go w podglądzie, i jest to ten sam podgląd co w panelu czatu.** Obraz jest obrazem, PDF to własny widok PDF przeglądarki, audio i wideo grają we własnym odtwarzaczu przeglądarki, markdown oferuje _Preview_ i _Source_ — oba są plikiem, a `#`, które po cichu stało się dużą czcionką, to sposób, w jaki ktoś nie zauważa, że jego agent pisze markdown do czegoś, co nie czyta tego jako markdown — a wszystko inne to jego tekst. Pobieranie jest zawsze dostępne, także dla tego, czego w ogóle nie da się pokazać. Jeden komponent, bo „otwórz ten plik” znaczące dwie różne rzeczy na dwóch ekranach to sposób, w jaki drugiemu z nich zaczyna brakować jakiegoś przypadku. Bajty pochodzą z `GET /sandbox-workspaces/{id}/raw?path=…` albo z `GET /conversations/{id}/workspace/raw?path=…` dla panelu obok czatu. Dwie trasy, a nie jedna, bo autoryzują różnych wołających — trasa rozmowy jest osiągana przez pobranie rozmowy, więc ktoś, komu czat _udostępniono_, zachowuje dostęp — i jeden moduł decyduje, co można wyświetlić, żeby odpowiedź nie mogła się różnić w zależności od powierzchni. Prawie wszystko jest serwowane jako załącznik. **Obrazy rastrowe, PDF-y, audio i wideo** (MP3, WAV, Ogg, Opus, M4A, MP4, WebM) są serwowane do wyświetlenia: raster, bo nie może się wykonać, PDF, bo przeglądarka renderuje go we własnym podglądzie, który nigdy nie dostaje DOM strony, a audio i wideo, bo przeglądarka przekazuje je swojemu odtwarzaczowi, który je odtwarza i niczego w stronie nie uruchamia. !!! danger "SVG i HTML są do pobrania i nigdy do wyświetlenia" SVG serwowany inline z tego origin to trwały cross-site scripting napisany przez cokolwiek, co agent postanowił zapisać, a „agent to napisał” nie jest granicą zaufania. Wszystko inne dostaje typ `application/octet-stream` z `X-Content-Type-Options: nosniff`, więc przeglądarka nie może uznać, że taka treść jest jednak HTML-em. Nazwa pliku podróżuje wyłącznie jako `filename*`, bo ścieżka w workspasie może zawierać dowolny UTF-8, a goła forma nie ma jak tego powiedzieć. Tylko **przechowywany** workspace może serwować dowolne bajty. Ten oparty na kontenerze jest czytany przez archiwum workspace'u, którego jedyny czytnik jest tekstowy, więc plik tekstowy jest serwowany przez zakodowanie go, a cokolwiek innego zostaje odrzucone, zamiast po cichu zniekształcone — przeglądarka oferuje pobranie obok odmowy, żeby odpowiedź nigdy nie była ślepą uliczką. Pliki są czytane tylko wtedy, gdy workspace zostaje otwarty albo gdy włączony jest widok płaski: wdrożenie może trzymać po jednym na każdą ciepłą rozmowę, więc czytanie każdego, żeby wyrenderować tabelę, byłoby żądaniem na wiersz dla strony, o nic jeszcze niepytanej. Widok płaski jest z tego samego powodu ograniczony i mówi o tym — ile workspace'ów odczytał, ilu nie mógł i czy istnieją kolejne. Krótsza lista jest inaczej nie do odróżnienia od mniejszej liczby plików. **Kto widzi który workspace, rozstrzyga się per czytelnik, w zapytaniu.** Wołający trzymający `connections:manage` widzi workspace'y organizacji — uczciwa poprzeczka dla listy, która przecina czaty nie jego. Wszyscy pozostali widzą workspace'y, których są częścią: własne pliki o zasięgu `user`, workspace'y własnych rozmów i współdzielony workspace agenta, z którym rozmawiali. „Rozmawiali”, a nie „mogliby otworzyć”, celowo: zasięg `agent` dzieli jeden workspace między użytkowników agenta, a panel czatu i tak pokazuje te pliki każdemu w rozmowie z nim, więc _możliwość_ otwarcia agenta jest szerszym roszczeniem niż to, które ta lista wypowiada. Zasięg `channel` jest widoczny wyłącznie dla operatora, co jest poprawne, a nie jest przeoczeniem — jest kluczowany na czacie Slacka albo Telegrama, więc ludzie, którzy go dzielą, są identyfikowani przez tamtą platformę, a nie przez wiersz w `users`. Workspace pobrany po id stosuje te same trzy predykaty i odpowiada **not found**, a nie forbidden, kiedy zawiodą: id nie może dać się użyć do odkrycia, jakie workspace'y istnieją w rozmowie kolegi. Nic tutaj nie przecina organizacji — admin aplikacji przeglądający pliki innego tenanta byłby tym jednym odczytem, który ta platforma odrzuca, więc przełącza organizację jak każdy inny. **Workspace oparty na kontenerze jest czytany z wolumenu hosta, a ten musi istnieć.** Usługa sandboksa serwuje te pliki z `SANDBOXD_WORKSPACE_ROOT` i to właśnie pozwala rozmowie sprzed miesiąca wylistować swoje pliki po tym, jak jej sesja została zebrana — żaden kontener nie jest do tego uruchamiany. Usługa skonfigurowana _bez_ niego nie trzyma nic na dysku, więc jej pliki istnieją tylko dopóki sandbox działa i nie da się ich odczytać bez uruchomienia go: panel Files mógłby wtedy tylko to powiedzieć, o pliku, który agent demonstracyjnie dopiero co zapisał. Każdy plik compose więc go ustawia, z możliwością nadpisania przez `SANDBOX_WORKSPACE_ROOT` — zmienną środowiskową tam, gdzie compose ją interpoluje, więc w katalogu głównym projektu, a nie w `backend/.env`, poza celami `dev` i `prod`, które przekazują ten plik jawnie. Jedna ścieżka na hoście, bind-mountowana w tym samym miejscu po obu stronach, bo usługa tworzy katalog, a potem prosi _demona_ o zamontowanie go — a demon rozwiązuje ścieżkę na hoście. Nazwany wolumen albo jakakolwiek ścieżka istniejąca wyłącznie wewnątrz kontenera usługi zostaje odrzucona z `mounts denied`. | | Domyślnie | | | --- | --- | --- | | Lokalny dev | `/tmp/agenticos-sandbox-workspaces` | Docker Desktop go współdzieli i każdy może do niego pisać, więc laptop nie potrzebuje żadnej konfiguracji | | Pliki na serwerze | `/var/lib/agenticos/sandbox-workspaces` | Musi istnieć i być zapisywalna dla uid 10001 — `sudo mkdir -p && sudo chown 10001:10001 `, raz. Nie `install -d -o 10001`: `install` rozwiązuje właściciela przez bazę passwd i odrzuca uid, którego nie ma żadne konto. Powinna leżeć na pamięci, którą ktoś backupuje | Reboot zamiata `/tmp` i to jedyny powód, żeby nie kierować tam prawdziwego wdrożenia. To jest raportowane, a nie podnoszone jako wyjątek. Każda lista niesie `unreadable_reason`, a klient pokazuje to jako wyjaśnienie zamiast jako błąd — bo żadna z przyczyn nie jest usterką: usługa nietrzymająca niczego na dysku to konfiguracja z jednolinijkową poprawką, którą komunikat nazywa, a host, który jest wyłączony, później będzie włączony. Podnoszenie wyjątku robiło z tego 500, które przeglądarka mogła wyrenderować tylko jako „coś poszło nie tak”, obok pustej listy, która czyta się jako „nie ma żadnych plików”. Dwie złe odpowiedzi naraz. Odczyt _jednego pliku_ z takiego hosta zostaje odrzucony tym samym zdaniem, zamiast zgłoszony jako „nie ma takiego pliku”, co mówiłoby, że pliku brakuje, podczas gdy go nie brakuje. **To, co działa, też jest czytane z usługi.** Ekran Sandboxes trzyma to na własnej zakładce, z dala od tabeli połączeń, i listuje otwarte sandboksy tej organizacji na hoście, który nazywa — domyślne połączenie, dopóki operator nie wybierze innego. Każdy wiersz niesie runtime, to, co dzieli ten sandbox, jego czas bezczynności i jego pamięć względem własnego sufitu, gdy się o nią zapyta. Sortowalne po czasie bezczynności i po pamięci. Obok jest log aktywności per sandbox: które ścieżki odczytano, jakie polecenia uruchomiono i jak każde poszło. Ani treść plików, ani wyjście poleceń nie są przez usługę zapisywane, i to właśnie powstrzymuje ślad audytowy przed staniem się sposobem na czytanie pracy cudzego agenta. Dashboard odpowiada na te same trzy pytania we własnej sekcji, dla wołającego trzymającego `connections:manage`. Pamięć jest tam za przełącznikiem z tego samego powodu co na ekranie: usługa próbkuje dla niej każdy sandbox z osobna. **Wszystkie trzy sufity teraz dzielą.** Lista sesji jest filtrowana do organizacji wołającego, ale niesie `SANDBOXD_MAX_SESSIONS` i `SANDBOXD_MAX_OPEN_SESSIONS` przepuszczone z usługi bez zmian — więc te dwa liczą każdego tenanta na hoście, podczas gdy wiersze liczą jednego. `len(sessions)` dzieli się wyłącznie przez `SANDBOXD_MAX_SESSIONS_PER_TENANT`. Dlatego odpowiedź niesie dla tej drugiej pary dwa liczniki obejmujące cały host, wzięte z niefiltrowanej listy, zanim filtr ją zawęzi: - `host_session_count` — rezydentne sandboksy, które usługa oznacza jako `state == "running"`, względem `limit`; - `host_open_count` — każda istniejąca sesja, rezydentna czy zhibernowana, względem `open_limit`. Teraz karta pojemności może powiedzieć, dlaczego sesja została odrzucona, choć tej organizacji brakuje do własnego sufitu: to sam host jest pełen cudzej pracy. To, że te dwie liczby obejmują cały host, jest celowym, wąskim ujawnieniem — dwie zagregowane liczby całkowite, które nikogo nie nazywają, daleko od wierszy sesji, które filtr zatrzymuje — a lista jest bramkowana na `connections:view`, uprawnieniu do obserwowania hosta, a nie uprawnieniu któregokolwiek członka. Na połączeniu Daytona są `None`, bo ono nie egzekwuje żadnych naszych sufitów, które można by dzielić. Ta lista jest **filtrowana, a nie przekazywana dalej**. Jeden `sandboxd` odpowiada każdej organizacji, która zarejestrowała połączenie pod jego adresem, więc przepuszczenie jego odpowiedzi pokazałoby jednemu tenantowi kontenery drugiego. Sesje są dopasowywane po etykiecie `tenant`, którą ta platforma ustawia przy ich otwieraniu, i nazywane z `agent_workspaces`, a nie przez dekodowanie id sesji — id koduje klucz zasięgu, a parsowanie go z powrotem zrobiłoby z tego formatu schemat. **To, na co usługa pozwala, jest czytane z usługi.** Allowlista runtime'ów i sufit za każdym aliasem (`SANDBOXD_RUNTIMES`, `SANDBOXD_MEM_LIMIT`, `SANDBOXD_NETWORK_MODE`, `SANDBOXD_MAX_SESSIONS_PER_TENANT` i reszta) to jej własna konfiguracja startowa i celowo nie ma endpointu do ich zapisu: przeglądarka, która mogłaby przekonfigurować proces trzymający socket Dockera, miałaby na własność host. Ekran Sandboxes i karta runtime'ów na dashboardzie oba je _czytają_, żeby widać było, co obowiązuje, a Builder oferuje agentowi wyłącznie te aliasy, które usługa naprawdę przyjmie. Żaden z tych widoków nie pyta połączenia Daytona o nic z tego. Nie publikuje ono własnej allowlisty i nie trzyma żadnych naszych sesji do wyliczenia — to, na co pozwala, jest ustawieniem na tamtym koncie, a to, co tam działa, widać w jego własnym dashboardzie. ## Kanały komunikacyjne { #messaging-channels } | Zmienna | Domyślnie | Opis | | --- | --- | --- | Poświadczenia botów nie są konfigurowane tutaj: każdy bot jest rejestrowany w aplikacji, a jego token zapieczętowany w vaulcie, przy czym bot Slacka niesie dodatkowo signing secret własnej aplikacji i token `xapp-` (`SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET` i `SLACK_APP_TOKEN` zostały usunięte — każdy bot jest teraz własną aplikacją Slacka). URL-e webhooków Telegrama są budowane z `PUBLIC_BASE_URL` (`TELEGRAM_WEBHOOK_BASE_URL` został usunięty), profile modeli mogą wskazywać na lokalne endpointy takie jak Ollama bez żadnej flagi (`ALLOW_INTERNAL_MODEL_ENDPOINTS` zostało usunięte), a limity sandboksa dla `run_python` są konfiguracją capability per agent (`CODE_EXECUTION_TIMEOUT_SECS` / `CODE_EXECUTION_MAX_MEMORY_MB` zostały usunięte). ## Rozmowy głosowe { #voice-calls } [Rozmowy głosowe](voice.md) włącza się w każdym agencie, a ogranicza je jego najdłuższa rozmowa i budżety. Dwa sufity należą do deploymentu: | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `VOICE_MAX_CALLS_PER_USER` | `1` | Rozmowy, które jedna osoba może prowadzić jednocześnie. Rozmowa z drugiej karty to prawie zawsze pomyłka, a każda jest rozliczana; drugie rozpoczęcie jest odrzucane jako `busy` | | `VOICE_MAX_CALLS_PER_EMBED` | `3` | Rozmowy, które jeden widżet albo jedna hostowana strona może prowadzić jednocześnie, od wszystkich odwiedzających razem; następna jest odrzucana jako `line_busy`. Odwiedzający nie mają konta, na które można by liczyć, więc to właśnie powstrzymuje wyciekły link przed równoległym zużywaniem budżetu organizacji | Oba liczą rozmowy ze świeżym heartbeatem, więc rozmowa, której proces padł, przestaje się liczyć w ciągu 45 sekund. Rozpoczynanie rozmów w embedzie ma też rate limit (`RATE_LIMIT_EMBED_VOICE_*`, [niżej](#rate-limiting)). Własny `rate_limit_per_minute` embeda, ustawiany w Builderze, liczy **wiadomości** odwiedzającego, a nie rozpoczęcia rozmów, więc nie ogranicza rozmów. Reszta jest ustalona w kodzie, a nie konfigurowana: rozmowa nigdy nie trwa dłużej niż 50 minut, heartbeaty idą co 15 sekund, a sprzątanie rozmów głosowych działa co minutę, jako harmonogram workera `reap-voice-calls`. ## CORS { #cors } | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `CORS_ORIGINS` | `["http://localhost:3000","http://localhost:8080"]` | Dozwolone originy (tablica JSON) | | `CORS_ALLOW_CREDENTIALS` | `true` | Zezwalaj na credentials (ciasteczka) | | `CORS_ALLOW_METHODS` | `["*"]` | Dozwolone metody HTTP | | `CORS_ALLOW_HEADERS` | `["*"]` | Dozwolone nagłówki HTTP | Walidacja produkcyjna: `CORS_ORIGINS` nie może zawierać `"*"` przy `ENVIRONMENT=production`. ## Ograniczanie liczby żądań { #rate-limiting } Stosowane do powierzchni, do których może sięgnąć obcy, i tylko do nich: publicznego API runów, skryptu widżetu, jego configu, handshake'u socketu którejkolwiek z tych powierzchni, configu i logo hostowanej strony oraz uploadu, dyktowania i rozmowy głosowej odwiedzającego. Własne trasy konsoli są za sesją i nie są mierzone, z jednym wyjątkiem: zapisami do [Virtual Tables](virtual-tables.md), które przechowują migawkę każdej zmiany. Czy całe API powinno nosić sufit, to osobna decyzja, nie ta. | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `RATE_LIMIT_RUN_PER_MINUTE` | `30` | `POST /api/v1/agents/{id}/run`, na wołającego | | `RATE_LIMIT_AUTH_PER_MINUTE` | `10` | Każda trasa z `auth.py` — logowanie, rejestracja, odświeżenie, trasy prośby i weryfikacji dla resetu i magic linku. Liczone **per IP oraz, gdy treść go niesie, per przesłany adres**. Zobacz niżej | | `RATE_LIMIT_EMBED_PER_MINUTE` | `20` | Na adres, i **dwa osobne liczniki tej wielkości**: jeden dla `widget.js`, jeden dla wpuszczenia — `/config` widżetu plus handshake socketu którejkolwiek z powierzchni. Zobacz niżej | | `RATE_LIMIT_HOSTED_PAGE_PER_MINUTE` | `240` | Config hostowanej strony, **na stronę** — oraz jej logo, na osobnym liczniku. [Formularz](workflows.md#from-a-form) workflow jest odczytywany na liczniku tej wielkości, na formularz. Zobacz niżej | | `RATE_LIMIT_FORM_SUBMISSIONS_PER_MINUTE` | `20` | Odpowiedzi, które przyjmuje jeden [formularz](workflows.md#from-a-form) workflow, **na formularz**: każda to run, za który płaci jego wydawca, a serwer konsoli przekazuje je wszystkie, więc adres nie liczyłby nikogo | | `RATE_LIMIT_WORKFLOW_WEBHOOK_PER_MINUTE` | `60` | Dostarczenia, które przyjmuje jeden [webhook](workflows.md) workflow albo wyzwalacz GitHub, **na webhook**, liczone, gdy podpis się zgadza, a dostarczenie uruchomiłoby run, więc obcy i pominięte dostarczenia nic z niego nie zużywają: każde to run | | `RATE_LIMIT_WORKFLOW_WEBHOOK_IP_PER_MINUTE` | `600` | Żądania, które jeden adres może wysłać pod adresy webhooków workflow i wyzwalaczy GitHub w ciągu minuty, niezależnie od treści, liczone zanim cokolwiek zostanie odczytane. Szeroki, bo nadawcy tacy jak GitHub dostarczają dla wielu hooków z kilku adresów | | `WORKFLOW_EMAIL_MAX_PER_PRINCIPAL_PER_HOUR` | `100` | Unikalni odbiorcy e-maili workflow na użytkownika, łącznie dla kluczy i organizacji; [email.send](reference/workflow-nodes.md#email-send) | | `WORKFLOW_EMAIL_MAX_PER_ORG_PER_HOUR` | `500` | Unikalni odbiorcy e-maili workflow na organizację; [email.send](reference/workflow-nodes.md#email-send) | | `WORKFLOW_EMAIL_MAX_PER_DEPLOYMENT_PER_HOUR` | `1000` | Unikalni odbiorcy e-maili workflow w całym deploymencie; [email.send](reference/workflow-nodes.md#email-send) | | `RATE_LIMIT_MAP_TILES_PER_MINUTE` | `600` | Kafelki map, które jeden adres może pobrać przez [ścieżkę kafelków](artifacts.md#visuals) deploymentu, **na adres**. Mapa to kilkadziesiąt kafelków, a przesuwanie dodaje kolejne; limit nie pozwala zrobić z deploymentu darmowego proxy do serwera kafelków | | `RATE_LIMIT_EMBED_UPLOAD_PER_MINUTE` | `5` | Pliki, które odwiedzający może zapisać na hostowanej stronie. Liczone **na adres i na klucz odwiedzającego**, a pozwolić muszą oba — klucz bije przeglądarka, więc liczenie tylko jego niczego nie ogranicza | | `RATE_LIMIT_ML_PER_MINUTE` | `30` | [Usługi ML](ml-services.md), na wywołującego. Te endpointy wykonują pracę synchronicznie, więc nieograniczony wywołujący zajmuje pulę parsowania, a nie budżet | | `RATE_LIMIT_DICTATION_PER_MINUTE` | `10` | [Dyktowanie w czacie](ml-services.md#dictation-in-chat), na osobę, na osobnym liczniku, żeby dyktowanie i integracja wywołująca usługi ML nie zużywały sobie nawzajem limitu. Każde dyktowanie to wywołanie płatnego providera organizacji | | `RATE_LIMIT_VOICE_PREVIEW_PER_MINUTE` | `6` | [Odsłuchanie głosu](voice.md#turn-it-on) w Builderze, na osobę. Każdy podgląd spoza cache to krótka płatna sesja u providera realtime agenta, poza wszystkimi budżetami modelu; raz odsłuchany głos jest trzymany w cache przez tydzień | | `RATE_LIMIT_EMBED_DICTATION_PER_MINUTE` | `6` | [Dyktowanie w widżecie lub na hostowanej stronie](channels.md#dictation-on-a-widget-or-a-hosted-page), na adres, a w widżecie `jwt` także na odwiedzającego. Każde dyktowanie to wywołanie płatnego providera organizacji, poza każdym budżetem modelu | | `RATE_LIMIT_EMBED_DICTATION_PER_LINK_PER_MINUTE` | `10` | To samo, na stronę albo widżet, od wszystkich odwiedzających razem — sufit tego, ile kosztuje wyciekły link: tyle nagrań na minutę, każde do najdłuższego dyktowania organizacji. Zużywane dopiero po wpuszczeniu odwiedzającego | | `RATE_LIMIT_EMBED_VOICE_PER_MINUTE` | `3` | [Rozmowy głosowe](channels.md#voice-calls-on-a-widget-or-a-hosted-page) rozpoczynane w widżecie albo na hostowanej stronie, na adres, a w widżecie `jwt` także na subject tokena odwiedzającego. Adres jest liczony, zanim klucz zostanie odnaleziony; każda rozmowa to płatna sesja realtime | | `RATE_LIMIT_EMBED_VOICE_PER_LINK_PER_MINUTE` | `6` | To samo, na stronę albo widżet, od wszystkich odwiedzających razem, zużywane dopiero po wpuszczeniu odwiedzającego. Razem z `VOICE_MAX_CALLS_PER_EMBED` wyżej — sufit tego, ile kosztuje wyciekły link | | `RATE_LIMIT_ARTIFACT_PUBLIC_DATA_PER_MINUTE` | `60` | Ile razy dane jednej strony mogą być czytane z jej tabel dla odwiedzających jej publiczny link albo embedy, **per strona dla wszystkich odwiedzających łącznie**. Ładowanie, które musi czytać z tabel, liczy się raz, a to obsłużone z 30-sekundowego [cache](artifacts.md#the-cache-and-the-cap) nie; po przekroczeniu limitu strona ładuje się bez tych danych | | `RATE_LIMIT_ARTIFACT_ACTIONS_PER_MINUTE` | `10` | Runy, które jeden członek może uruchomić z [akcji](artifacts.md#actions) jednej strony w ciągu minuty, **na członka, organizację i stronę**. Każdy run członek najpierw potwierdza; limit ogranicza stronę, która prosi w pętli. Po jego przekroczeniu wywołanie strony jest odrzucane jako `rate_limited` | | `RATE_LIMIT_TABLE_WRITES_PER_MINUTE` | `300` | Zapisy do [Virtual Tables](virtual-tables.md), **na członka i organizację**: create, update, upsert lub delete rekordu create, zmiana nazwy, archiwizacja lub zmiana schematu tabeli oraz zapisanie, zmiana lub usunięcie zapisanego widoku. Dotyczy konsoli tak samo jak skryptu. Odczyty nie są liczone | | `RATE_LIMIT_TRUST_FORWARDED_FOR` | `false` | Czy `X-Forwarded-For` nazywa wołającego | **Co dostaje odrzucony wołający** to własna koperta błędu tego API z `code: "RATE_LIMIT_EXCEEDED"`, interwałem w `error.details.retry_after_seconds` i tym samym interwałem w nagłówku `Retry-After` — który jest tym, na którym faktycznie wycofuje się wrapper fetcha albo CDN. Wyjątkiem jest handshake socketu, bo WebSocket nie ma statusu, którym mógłby odpowiedzieć: zamyka się kodem `4029` (zobacz [kanały](channels.md#the-raw-websocket)). **Dwa liczniki, nie jeden, a powód jest arytmetyczny.** Załadowanie strony z widżetem kosztuje trzy żądania do tego API: skrypt, config i socket. Liczone razem, `20` kupowało mniej więcej siedem załadowań strony dla zimnej przeglądarki, a nie dwadzieścia wpuszczeń — a limit mylny o czynnik trzy jest gorszy niż brak limitu, bo czyta się jako liczba, którą ustawiłeś. Dlatego `widget.js` ma własny kubełek. Jest cacheowalny, a odmowa tam psuje widżet całkowicie, zamiast opóźnić jedną wiadomość. Config i handshake zostają razem, bo razem _są_ jednym wpuszczeniem: przeglądarka, która przeczytała config i nie otworzyła socketu, nie weszła. Liczniki żyją w Redisie wdrożenia, więc trzymają się w poprzek workerów — produkcja uruchamia cztery, a licznik trzymany per proces przepuszczałby czterokrotność tego, co deklaruje. Jeśli Redis jest nieosiągalny, limit nie jest stosowany, a w logu ląduje ostrzeżenie: odmówienie odwiedzającemu odpowiedzi, bo cache mrugnął, jest gorszą z tych dwóch porażek. To, co odwiedzający może _powiedzieć_ po wpuszczeniu, to inna liczba, ustawiana per widżet w Builderze (`rate_limit_per_minute`) i liczona per odwiedzający. Te dwie są sufitem na samo wejście. ### `RATE_LIMIT_HOSTED_PAGE_PER_MINUTE` i dlaczego nie jest liczony na adres { #rate_limit_hosted_page_per_minute-and-why-it-is-not-per-address } Config hostowanej strony jest pobierany **po stronie serwera**, przez frontend, żeby strona pomalowała się w brandingu już na pierwszej klatce. Znaczy to, że adres w żądaniu należy do kontenera frontendu, a nie do odwiedzającego — więc liczenie go wrzucało każde załadowanie hostowanej strony w całym wdrożeniu do jednego kubełka, a odwiedzający, który go przepełnił, dostawał 404 bez niczego, co by mówiło dlaczego. `RATE_LIMIT_TRUST_FORWARDED_FOR` nic tu nie pomoże: `fetch` po stronie serwera nie wysyła takiego nagłówka, któremu ktokolwiek mógłby zaufać. Dlatego ten jest liczony na klucz publiczny. Ogranicza pojedynczą stronę, zamiast racjonować odwiedzającego, i dlatego wartość domyślna jest szeroka — **to nie to ogranicza wydatki.** Wydatki zaczynają się przy sockecie, który strona otwiera w następnej kolejności, który robi przeglądarka i który jest liczony na adres pod `RATE_LIMIT_EMBED_PER_MINUTE`. A zgadywanie klucza nie jest strategią przeciwko 192 bitom z `secrets.token_urlsafe`. ### `RATE_LIMIT_AUTH_PER_MINUTE` i dlaczego powierzchnia auth ma własny { #rate_limit_auth_per_minute-and-why-the-auth-surface-has-its-own } Każda trasa w `auth.py` niesie ten limit, liczony **per IP** oraz — gdy treść niesie adres (logowanie, rejestracja, prośby o reset i o magic link) — **także per przesłany adres**, oba w ramach tego samego przydziału. Te dwa zatrzymują różne ataki: IP ogranicza zalew z jednego źródła, adres ogranicza atak siłowy na jedno konto. Jest osobny od przydziału dla runów i niższy od niego, bo broni kosztu **pojedynczej próby**. `verify_password` to bcrypt, ~170 ms bez punktu zawieszenia, więc niemierzony zalew `/login` dla dowolnego adresu, który ma konto, nasyca pętlę zdarzeń workera w ogóle bez żadnych poświadczeń. Resztę tej powierzchni zamykają jeszcze dwie rzeczy i nie potrzebują żadnej konfiguracji: - bcrypt działa w wątku, więc nigdy nie blokuje pętli; - adres **bez** konta jest weryfikowany względem atrapy hasha, a nie pomijany, więc adres znany i nieznany zajmują tyle samo czasu do odmowy, a czas nie mówi już, które adresy istnieją. ### `RATE_LIMIT_TRUST_FORWARDED_FOR` i dlaczego jest wyłączony { #rate_limit_trust_forwarded_for-and-why-it-is-off } Limity na adres liczą `request.client.host`. **Za proxy albo CDN-em jest to adres proxy, a nie odwiedzającego** — każdy odwiedzający dzieli jeden kubełek, więc ruchliwa strona za Cloudflare wyczerpuje dwadzieścia wpuszczeń widżetu na minutę dla wszystkich naraz. Włączenie tego każe czytać zamiast tego **skrajnie prawy** przeskok z `X-Forwarded-For` — adres, który dopisało samo zaufane proxy. Jest domyślnie wyłączone, bo nagłówek ustawia ten, kto woła. Zaufany bezwarunkowo, limit na adres staje się limitem na nagłówek, który każdy obchodzi, zmieniając jeden ciąg znaków. **Skrajnie prawy** przeskok jest czytany zamiast skrajnie lewego z tego samego powodu: `X-Forwarded-For` to lista, którą zaczyna klient i do której dopisuje każde proxy, więc głowa jest tym, co wpisał klient, a tylko ogon tym, co napisało kontrolowane przez ciebie proxy. **Powierzchnia auth też tego potrzebuje, a frontend teraz to umożliwia.** Żądania auth docierają do API po stronie serwera, przez własne trasy frontendu `/api/auth/*`, więc bez pomocy adres na nich należy do kontenera frontendu, a połowa `RATE_LIMIT_AUTH_PER_MINUTE` licząca per IP wrzuca całe wdrożenie do jednego kubełka — jakieś jedenaście logowań i wszyscy są zablokowani na minutę, a wyczerpany kubełek odświeżania wylogowuje sesje. W odróżnieniu od pobrania configu hostowanej strony te trasy **przekazują `X-Forwarded-For` wołającego** ([#1047](https://github.com/vstorm-co/agenticos/issues/1047)), więc z tym ustawieniem włączonym limit kluczuje na prawdziwym kliencie. Włącz je dla limitu auth na tej samej zasadzie co wszystko inne — jedno kontrolowane przez ciebie proxy z przodu, dopisujące klienta jako skrajnie prawy przeskok — czyli decyzja wdrożeniowa, którą to ustawienie jest; zostawione wyłączone, limit pozostaje bezpieczny, ale współdzielony. !!! danger "Włącz to tylko wtedy, gdy jedynym, co może sięgnąć API, jest jedno kontrolowane przez ciebie proxy" Jeśli port kontenera jest też opublikowany, wołający może sam ustawić nagłówek i limit przestaje cokolwiek znaczyć. **Port frontendu liczy się tu jako port API.** Jego trasy `/api/auth/*` przekazują dalej dowolny `X-Forwarded-For`, który dostały, więc wołający, który potrafi sięgnąć portu 3000 z pominięciem proxy, wybiera adres, na który liczone są jego próby logowania, dokładnie tak samo jak ten, kto potrafi sięgnąć portu 8000 — a każda przyjęta próba na adres, którego nikt nie ma, i tak kosztuje jeden bcrypt. Dlatego zarówno `docker-compose-prod.yml`, jak i `docker-compose-prod.frontend.yml` publikują domyślnie na `127.0.0.1`, gdzie sięga do nich reverse proxy hosta i nic więcej. `BIND_HOST=0.0.0.0` otwiera je z powrotem, dla proxy, które naprawdę działa gdzie indziej — z siecią tamtego proxy jako tym, co trzyma obietnicę. Przy dwóch proxy z przodu zwiń nagłówek do jednego przeskoku na swojej krawędzi — wiarygodny jest tylko ostatni przeskok. ## Limity i retencja Virtual Tables { #virtual-tables-limits-and-retention } Ile jedna organizacja może przechowywać w [Virtual Tables](virtual-tables.md#limits-and-retention) i jak długo trzymane są kopie, które zostawia zapis. Zapis ponad limit jest odrzucany kodem `QUOTA_EXCEEDED` (402) i wpisem audytu, który nazywa limit, nigdy treść. | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `TABLES_MAX_PER_ORGANIZATION` | `200` | Tabele na organizację. Zarchiwizowane się liczą, bo nic nie usuwa tabeli | | `TABLES_MAX_RECORDS_PER_TABLE` | `100000` | Rekordy w jednej tabeli | | `TABLES_MAX_RECORD_BYTES` | `1000000` | Rozmiar zserializowanych wartości jednego rekordu w bajtach. Minimum `1`. Ogranicza też to, co trzymają wiersz history przy create i delete oraz receipt; rekord już ponad limitem nadal się usuwa, zachowując znacznik z liczbą bajtów zamiast wartości | | `TABLES_MAX_STORAGE_BYTES_PER_ORGANIZATION` | `1073741824` | Bajty danych JSONB PostgreSQL na organizację łącznie dla rekordów, historii, potwierdzeń i outboxa; narzut tabel i indeksów jest osobny | | `TABLES_RECEIPT_TTL_HOURS` | `24` | Jak długo idempotentny receipt odpowiada na ponowienie. Potem ten sam klucz to nowy zapis | | `TABLES_OUTBOX_RETENTION_DAYS` | `3` | Jak długo trzymany jest wysłany wiersz outbox | | `TABLES_OUTBOX_UNDISPATCHED_RETENTION_DAYS` | `30` | Jak długo trzymany jest niewysłany wiersz outbox. Heartbeat [wyzwalaczy tabel](virtual-tables.md#triggers) oznacza każdy oceniony wiersz, więc wiersz tak stary znaczy, że worker tyle nie działał. To dead-letter cutoff, a nie deklaracja, że zdarzenie zostało dostarczone - po tym czasie wiersz znika i dla tego rekordu żaden wyzwalacz nie wystartuje | | `TABLES_HISTORY_RETENTION_DAYS` | `365` | Jak długo trzymana jest history rekordu, liczona od zmiany, także dla usuniętego rekordu | | `TABLES_MAX_CONCURRENT_QUOTA_AUDITS` | `4` | Ile wpisów audytu odmowy limitu ten proces zapisuje naraz, żeby seria odmów nie otwierała nieograniczonej liczby połączeń do bazy. Reszta serii czeka na to ograniczenie | Trzy okresy retencji stosuje codzienny [sweep retencji](governance.md#retention), dla każdej organizacji; nie są ustawieniami per organizacja. Budżet jednego przebiegu dla tych trzech klas skaluje się z `RATE_LIMIT_TABLE_WRITES_PER_MINUTE` zamiast ze stałej liczby batchy - ale ten limit jest per _członek_ (`limit_table_write` liczy zapisy każdego członka na jego własnym koncie), więc budżet skaluje się też z liczbą aktywnych członków organizacji: aż do `RATE_LIMIT_TABLE_WRITES_PER_MINUTE * 60 * 24` wierszy na aktywnego członka dziennie, w batchach po 500. Ta liczba jest podwajana dla zapasu, żeby istniejąca zaległość się kurczyła, a nie tylko utrzymywała na stałym poziomie, i ograniczona do 50 członków, żeby jedna nietypowo duża organizacja nie rozrastała własnego przebiegu bez końca - nadal się drenuje, tylko w kilku przebiegach, tak jak każda inna klasa retencji, gdy zaległość przerośnie swój budżet. ## Worker, którego pętla zdarzeń przestała się kręcić { #a-worker-whose-event-loop-has-stopped-turning } | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `EVENT_LOOP_WEDGED_AFTER` | `15` | Ile sekund pętla zdarzeń może się nie kręcić, zanim worker zostanie zabity i zastąpiony. `0` lub mniej wyłącza to sprawdzanie | | `UVICORN_WORKER_HEALTHCHECK_TIMEOUT` | `60` | Ile sekund supervisor uvicorna czeka, aż worker odpowie na jego ping, zanim go zastąpi — wszędzie, gdzie `--workers` jest większe od 1. Czytają ją pliki compose, nie aplikacja | Worker, który _żyje, ale nie odpowiada_ — zakleszczony na blokadzie, kręcący się w synchronicznym wywołaniu, zablokowany na sockecie, który nigdy nie odpowie — nie ma kodu wyjścia, więc każda ścieżka odzyskiwania w każdym stacku czytała go jako zdrowego, podczas gdy żądania wygasały. Kontener robi się `unhealthy`, a status nie jest mechanizmem. Dlatego worker ocenia własną pętlę zdarzeń. Callback timera stempluje pętlę raz na sekundę; wątek czyta stempel, a jeśli pętla nie obróciła się przez `EVENT_LOOP_WEDGED_AFTER` w dwóch kolejnych sprawdzeniach, kończy proces — `SIGKILL` albo `os._exit(137)` tam, gdzie worker jest PID 1, bo jądro nie dostarcza initowi przestrzeni nazw sygnału, dla którego ten init nie ma handlera. Tak czy inaczej `docker inspect` raportuje `137`, a „zakleszczony”, czego nic nie obsługiwało, staje się „nieżywy”, co obsługuje już każdy stack: | Stack | Co zastępuje workera | | --- | --- | | `docker-compose.yml` | supervisor przeładowania, przy następnym odpytaniu | | `docker-compose-dev.yml` | PID 1 to serwer, więc kontener kończy pracę i działa `restart: unless-stopped` | | `docker-compose-prod.yml` | `Multiprocess` uvicorna, w jakieś pół sekundy; pozostałe trzy workery serwują dalej | Dwie właściwości są powodem tego projektu i obie warto znać, zanim zmieni się tę liczbę: - **Mierzy żywotność, a nie gotowość.** Stempel jest callbackiem timera, a nie żądaniem, więc wolna baza danych albo provider modelu, który potrzebuje dwudziestu sekund, nie jest zakleszczeniem — pętla się kręci, ona czeka. Próba HTTP miałaby mniej ruchomych części i wpadałaby w pętlę restartów na zdrowym serwerze wobec zepsutej zależności. - **Dwa sprawdzenia, nie jedno.** `docker pause`, zamrożona cgroupa i laptop budzący się ze snu zatrzymują watchdoga tak samo dokładnie jak pętlę, więc pierwsze sprawdzenie po czymś takim czyta nieaktualny stempel, który nic nie mówi. Supervisor przeładowania w lokalnym stacku czyta tę samą zmienną dla oceny, którą robi z _zewnątrz_ workera, więc jedna liczba obejmuje oba. !!! tip "Ustaw `0` na czas debugowania" Breakpoint blokuje pętlę zdarzeń i nic nie odróżni tego od zakleszczenia, więc worker, który na nim siedzi, jest inaczej zabijany pod tobą. Nie widzi procesu, który w ogóle nie działa — `kill -STOP`, zamrożona cgroupa — bo watchdog wewnątrz zatrzymanego procesu też jest zatrzymany. Ten przypadek pokrywają już supervisory: bicie supervisora przeładowania staje się nieaktualne, a ping po pipie w produkcji zostaje bez odpowiedzi. Ten ping czeka `UVICORN_WORKER_HEALTHCHECK_TIMEOUT` sekund, domyślnie 60. Domyślna wartość samego uvicorna to 5, a host, któremu brakuje CPU — steal na współdzielonym VPS-ie, budowanie obrazu, zaległe uruchomienia flow — nie mieści się w 5 sekundach z workerami, które żyją i obsługują ruch. Supervisor zabija je wtedy, a potem zabija ich następców, zanim skończą importować aplikację, i każdy restart dokłada import do presji, która go spowodowała. W logu widać to jako `Child process [N] died` bez wpisu watchdoga nad nim. Skoro zablokowaną pętlę ocenia watchdog, ping ma tylko zauważyć zatrzymany proces, a na to minuta wystarczy. ## Docker / produkcja { #docker-production } | Zmienna | Domyślnie | Opis | | --- | --- | --- | | `DOMAIN` | `example.com` | Domena produkcyjna (dla Traefika) | | `ACME_EMAIL` | `admin@example.com` | Adres e-mail dla Let's Encrypt do certyfikatów SSL | | `REDIS_PASSWORD` | `change-me-in-production` | Hasło Redisa dla produkcji | ## Lista kontrolna przed produkcją { #production-checklist } !!! danger "Każde z nich jest dostarczane z wartością domyślną, która w produkcji jest zła" Wdrożenie osiągalne skądkolwiek indziej ma wszystkie dziewięć ustawione świadomie. - [ ] `SECRET_KEY` — unikalny 64-znakowy klucz hex: `openssl rand -hex 32` - [ ] `API_KEY` — unikalny klucz: `openssl rand -hex 32` - [ ] `VAULT_MASTER_KEY` — unikalny klucz: `openssl rand -hex 32`. Konfiguracja odrzuca pusty poza `local`/`development` - [ ] `ENVIRONMENT` — `production` - [ ] `DEBUG` — `false` - [ ] `POSTGRES_PASSWORD` — silne, unikalne hasło - [ ] `REDIS_PASSWORD` — silne hasło - [ ] `CORS_ORIGINS` — wyłącznie twoje faktyczne domeny frontendu E-mail celowo **nie** jest na tej liście: wdrożenie działa bez niego. Ale zaproszenia, resety haseł i powiadomienia po cichu nie są wysyłane, dopóki `SMTP_HOST` i reszta z [E-mail (SMTP)](#email-smtp) nie wskażą prawdziwego serwera — więc wdrożenie, które to pomija, powinno pomijać to świadomie. ## Diagnostyka wdrożenia { #diagnostics } `DIAGNOSTICS_ENABLED` ma domyślną wartość `true`, również po aktualizacji ze starszym plikiem `.env` bez tego klucza. Ustaw `DIAGNOSTICS_ENABLED=false` w środowiskach backendu i frontendu i uruchom usługi ponownie, aby wyłączyć raportowanie. Ustawienia wdrożenia pokazują stan i ostatni pomyślny raport; nie mają przełącznika. `DIAGNOSTICS_COLLECTOR_URL` to adres kolektora dla serwera, a `DIAGNOSTICS_PUBLIC_URL` to adres dostępny z przeglądarki, odczytywany też przez frontend dla Content Security Policy. Oba domyślnie wskazują `https://analytics-agenticos.testapp.ovh`. Dla kolektora na hoście Docker Desktop ustaw serwerowi `http://host.docker.internal:8010`, pozostawiając publiczny adres `http://localhost:8010`. Zdalne adresy wymagają HTTPS. Nie podawaj ścieżek, poświadczeń ani parametrów zapytania. Trwała kolejka wysyła raport co godzinę. Ziarno Ed25519 jest szyfrowane przez vault, zapisywane przed pierwszym połączeniem, wspólne dla replik i obsługiwane przez `vault-rotate`. Niedostępny kolektor nie blokuje logowania, czatu ani startu workera. Raport zawiera wersję, stan bazy i workera, jawnie nieznany stan Redis oraz przedziały liczby uruchomień i utworzonych projektów, skilli i dokumentów z 30 dni. Wysyłane są wyłącznie nazwy z zamkniętego katalogu funkcji. Błędy obejmują nieudane uruchomienia, zadania zakończone trwale błędem i błędy ingestii z poprzedniej pełnej godziny UTC, maksymalnie 1000 na kategorię. Dowolne wyjątki API nie są eksportowane. Aktywność web, desktop i mobile używa podpisanych deklaracji ważnych 60 sekund, związanych z zalogowanym kontem. Identyfikator konta zastępuje pseudonim właściwy instalacji. Widoczne, aktywne okna raportują po interakcji najwyżej co pięć minut; bezczynne karty i karty w tle nie podtrzymują obecności. Przeglądarka wysyła deklarację bezpośrednio bez cookies i referrera, umożliwiając przybliżone grupowanie według kraju/regionu. Nie wysyłamy promptów, rozmów, nazw plików, nazw kont, adresów e-mail, kluczy dostawców ani treści wyjątków. Osobny [kolektor analytics](https://github.com/vstorm-co/agenticos-analytics) przechowuje zdarzenia przez siedem dni, a dzienną pseudonimową aktywność i agregaty przez 90 dni. Online oznacza aktywność w ostatnich dziesięciu minutach, aktywna instalacja — kontakt w ostatnich 24 godzinach. Brak kontaktu to stan nieznany, nie potwierdzona awaria. Kod kolektora nie zapisuje adresów IP ani pozycji GPS; retencja logów proxy należy do operatora. Podgląd mapy ma osobny tryb demo; AgenticOS nie dodaje fikcyjnych zdarzeń do diagnostyki produkcyjnej. ## Aktualizacje z panelu administracyjnego { #updates-from-the-administration-panel } Strona wdrożenia pokazuje changelog najnowszej wersji oraz stan przygotowania, planowania i odzyskiwania. Aktualizacje wymagają świadomego włączenia: zainstaluj helper na pojedynczym hoście Linux/systemd z Compose i podłącz API oraz worker nakładką updatera. Zobacz [instrukcję operatora i odzyskiwania](https://github.com/vstorm-co/agenticos/tree/main/deploy/updater). Przygotowanie sprawdza konfigurację i miejsce na dysku, pobiera obrazy wydania, przypina ich skróty i wykonuje wstępny backup bez restartu aplikacji. Aktualizuj od razu albo zaplanuj termin w ciągu siedmiu dni. Helper zatrzymuje przyjmowanie pracy, czeka na aktywne uruchomienia, zadania i rozmowy głosowe, zatrzymuje procesy zapisujące, wykonuje końcowy backup PostgreSQL, plików i konfiguracji, uruchamia migracje i test gotowości. Dopiero wtedy otwiera ruch. Stan pozostaje na hoście; panel łączy się ponownie po powrocie API. Błąd po rozpoczęciu migracji pozostawia tryb konserwacji do interwencji operatora; nie ma automatycznego cofania bazy. Backupy pozostają na hoście do ręcznego usunięcia i nie zastępują kopii poza serwerem. Najpierw przetestuj odzyskiwanie na środowisku jednorazowym. Wiele hostów, własne nakładki Compose i zewnętrzny magazyn plików obsługuj pipeline’em: `UPDATE_PIPELINE_URL` pokazuje link HTTPS bez uruchamiania wdrożenia.