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 |
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 |
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 |
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) |
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) |
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 |
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.
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 |
30 |
Timeout puli w sekundach |
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.
| 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 prefect-runner łą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
prefect-runner: *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 (Prefect)¶
| Zmienna | Domyślnie | Opis |
|---|---|---|
PREFECT_API_URL |
http://localhost:4200/api |
Self-hostowany serwer albo URL workspace'u Prefect Cloud |
PREFECT_API_KEY |
(none) | Tylko Prefect Cloud |
PREFECT_RUNNER_LIMIT |
5 |
Ile flow runów wykonuje się naraz; reszta czeka w kolejce |
PREFECT_RUNNER_SERVER_HOST |
127.0.0.1 w compose |
Interfejs, na którym runner serwuje własny endpoint zdrowia |
PREFECT_RUNNER_SERVER_PORT |
8080 |
Port do tego samego |
PREFECT_RUNNER_LIMIT to sufit pamięci, a nie pokrętło przepustowości. Każdy run
to osobny proces, który importuje całą aplikację — mniej więcej 120 MB — a liczbą,
która ma znaczenie, nie jest stan ustalony, tylko restart: runner wstaje, znajduje
każdy run zaplanowany w czasie, gdy go nie było, i startuje tyle, na ile pozwala
limit. Bez limitu trzy dni przestoju to 71 procesów i 6 GiB. Podnieś go, jeśli
ingestia kolejkuje się za synchronizacjami na maszynie z zapasem pamięci; obniż na
małym hoście.
Dwie zmienne PREFECT_RUNNER_SERVER_* należą do Prefecta, a pliki compose
przypinają je, żeby kontener runnera miał status zdrowia, który coś znaczy. Runner
startuje webserver runnera Prefecta, którego GET /health odpowiada 503, gdy
przegapi dwa odpytania API Prefecta — więc proces, który żyje, ale nie podejmuje
już pracy, czyta się jako unhealthy, a nie jako w porządku. Jest przypięty do
loopbacku, bo ten sam webserver wystawia też POST /shutdown; próba działa
wewnątrz kontenera i nic spoza niego nie sięgnie żadnego z nich. Przeniesienie
portu oznacza przeniesienie razem z nim próby w plikach compose.
W backend/Dockerfile nie ma HEALTHCHECK. Obraz jest uruchamiany jako dwa różne
procesy — API i ten runner — a próba dla jednego jest stałym fałszywym alarmem dla
drugiego, więc każda definicja usługi nosi 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 |
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 |
|---|---|---|
STALE_RUN_REAPED_AFTER_HOURS |
6 |
Jak długo run może stać w stanie running, zanim cogodzinne zamiatanie uzna, że jego proces umarł, i zakończy go jako failed. 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.
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)¶
| 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.
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 |
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.
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 |
4 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, 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 i PDF-y są serwowane do wyświetlenia: raster, bo nie może się wykonać, a PDF, bo przeglądarka renderuje go we własnym podglądzie, który nigdy nie dostaje DOM strony.
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).
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 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_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_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 |
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.
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.