Przejdź do treści

Konfiguracja

Cała konfiguracja jest zarządzana przez zmienne środowiskowe, wczytywane z backend/.env przy użyciu pydantic-settings.

Ustawienia są zdefiniowane w app/core/config.py i dostępne przez globalny obiekt settings:

from app.core.config import settings

print(settings.MAX_UPLOAD_SIZE_MB)
print(settings.DEBUG)

Na początek

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:

openssl rand -hex 32   # SECRET_KEY — signs every access token
openssl rand -hex 32   # VAULT_MASTER_KEY — unwraps every credential stored at rest

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

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); 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 — 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. 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
MEM0_ALLOWED_HOSTS [] (empty) Nazwy hostów, na które może wskazywać self-hostowana usługa pamięci mem0. base_url pochodzi ze speca agenta, więc bez allowlisty Builder, który może podpiąć (ale nie odczytać) współdzielony klucz mem0, mógłby wycelować go we własny serwer i przechwycić klucz z nagłówka żądania. Pusta wartość odrzuca self-hostowane mem0 i dopuszcza wyłącznie zarządzaną chmurę; dodaj zaufaną nazwę hosta, aby włączyć wdrożenie self-hosted. Zobacz sekrety
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 — dokładnie ten kształt problemu, dla którego istnieje MEM0_ALLOWED_HOSTS, i 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 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
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 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
MCP_ALLOWED_HOSTS [] (empty) Hosty, na które może wskazywać URL połączenia MCP, choć rozwiązują się na adres prywatny albo loopback, na przykład usługa compose: ["postgres-mcp"]. Dopasowanie jest dokładne i nie rozróżnia wielkości liter. Host z listy pomija tylko sprawdzenie adresu: 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 wskazujący usługę metadanych chmury, adres link-local albo loopback, taki jak 169.254.169.254 czy localhost, jest odrzucany przy starcie. Pusta lista odrzuca każdy adres wewnętrzny. Zobacz połączenie
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). 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
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)

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

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

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

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": "<old>", "2": "<new>"}. 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

Klucz API

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)

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). 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 → 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)

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; pusty zostawia członkostwa w spokoju. Odrzucany razem z LDAP_URL

Issuer to jedyny URL. Authorization, token, userinfo i JWKS biorą się z <issuer>/.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/<tenant-id>/v2.0 Redirect URI platformy Web w rejestracji aplikacji. Nadaj openid, email, profile w API permissions
Okta https://<org>.okta.com (albo /oauth2/<id> własnego serwera autoryzacji) Sign-in redirect URI w aplikacji typu Web
Keycloak https://<host>/realms/<realm> 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ć.

Przy ustawionym OIDC_GROUPS_CLAIM o członkostwach decydują grupy dostawcy: mapowania grup katalogowych 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.

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)

Logowanie kontem Active Directory, OpenLDAP albo FreeIPA przez bind jako to konto. Logowanie katalogowe i grupy 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

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.

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/<api host>@<REALM>. 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)

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
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

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). 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

Szyfrowane połączenia (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=<mode> dla asyncpg aplikacji, ?sslmode=<mode> 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.

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.

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.

# 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)

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

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

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,ingest,system Pule obsługiwane przez ten proces. Uruchom drugiego workera z ingest, żeby parsowanie miało własny host
WORKER_AGENT_CONCURRENCY 10 Runy agentów i odpalenia triggerów naraz
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ń

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, co to rozstrzyga i co celowo zostawia w spokoju.

Zbieranie porzuconych runów

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.

Tura czatu zostawiona w trakcie

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.

Modele AI — konfigurowane w aplikacji, nie tutaj

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)

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ę.

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ą <nazwa usługi>-worker, więc w jednym projekcie da się odróżnić wolny run zaplanowany od wolnej tury czatu.
  • Środowisko może nieść własny token zapisu, zapieczętowany w vaulcie, który przekierowuje związane z nim runy.
  • Blok 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.

Przy ENVIRONMENT=production żaden span nie trafia na konsolę. Eksporter konsolowy Logfire wypisuje jedną linię na każde wywołanie SQL i Redisa, około czterdziestu na zapytanie, do logu kontenera, którego nikt nie przeszuka. W produkcji span istnieje więc tylko tam, dokąd wysyła go token, a bez tokenu wolne zapytanie nie zostawia żadnego śladu. agenticos cmd doctor zgłasza to w linii tracing. W innych środowiskach konsola dalej wypisuje spany dla tego, kto patrzy na serwer.

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
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
Zmienna Domyślnie Opis

RAG (Retrieval Augmented Generation)

Baza wektorowa

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.

\"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).

Embeddingi

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.

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

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

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.

Plik to klucz konta serwisowego: konsola Cloud → 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

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ł

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.

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

Obrazy, mowa i wideo generowane przez agentów są trzymane w tym samym magazynie, pod generated_<org>/, 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, ż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

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

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.

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_ORIGIN (puste) Skąd serwowana jest treść - strony, ich zestaw bibliotek 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
VISUAL_MAP_TILES (puste) Jedyny serwer kafelków, na którym wizualizacja 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. Puste rysuje mapę jako 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 je 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.

Workspace'y agentów

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ć

Dostarczany jest jeden runtime — workbench (1,93 GB): Python 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. Jest zdefiniowany 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

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

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

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.

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 <path> && sudo chown 10001:10001 <path>, 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

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

Rozmowy głosowe 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). 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

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ń

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 — 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. Zobacz niżej
RATE_LIMIT_MAP_TILES_PER_MINUTE 600 Kafelki map, które jeden adres może pobrać przez ścieżkę kafelków 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, 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, 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 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, 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 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_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).

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

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

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

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), 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.

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.

Worker, którego pętla zdarzeń przestała się kręcić

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.

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

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ą

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) nie wskażą prawdziwego serwera — więc wdrożenie, które to pomija, powinno pomijać to świadomie.