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:
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_verifiedixms_edovEntra ID, które Entra wysyła zamiast niego —email_verifiednie emituje w ogóle, axms_edovjest 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_CLAIMnazywa 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, bosubjest 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_TOKENstą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
observabilityagenta 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 |
Wyszukiwanie w sieci¶
| 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:
- Socketa Dockera, bo usługa startuje kontenery. To root na tamtej maszynie, dlatego token poniżej jest wart tyle, ile jest.
SANDBOXD_WORKSPACE_ROOTna 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 zmounts denied.- TLS i token, którego nikt nie współdzieli. Wewnątrz compose adres to
http://sandboxd:8080w 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_AFTERjest hibernowana, żeby zwolnić slot, a jej następne żądanie ją budzi. Wymaga toWORKSPACE_ROOT, bo inaczej obudzenie otworzyłoby pusty workspace, więc usługa odrzuca tę kombinację, zamiast tak zrobić. - gone — po
SANDBOXD_IDLE_TIMEOUTsesja jest zamykana i zbierana. PrzyPERSIST_CONTAINERSkontener 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 jakostate == "running", względemlimit;host_open_count— każda istniejąca sesja, rezydentna czy zhibernowana, względemopen_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 pozalocal/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.