Konfiguration¶
Die gesamte Konfiguration läuft über Umgebungsvariablen, die mit
pydantic-settings aus backend/.env geladen werden.
Die Settings sind in app/core/config.py definiert und werden über das globale
Objekt settings gelesen:
Erste Schritte¶
make install legt backend/.env aus backend/.env.example an, wenn es keine
gibt, und fasst die Datei danach nie wieder an — auf einem frischen Checkout ist
also nichts zu kopieren und auf einem bestehenden nichts zu verlieren.
Bevor irgendetwas ein Netz erreicht, in dem noch jemand anderes ist, setzen Sie die Werte, die das Beispiel als Platzhalter ausliefert:
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 wird als veröffentlichte Zeichenkette ausgeliefert
Ein leerer VAULT_MASTER_KEY fällt darauf zurück, damit ein frischer
Checkout überhaupt läuft. Auf einem Laptop ist beides in Ordnung, überall
sonst ist es die gesamte Sicherheit eines Deployments. VAULT_MASTER_KEY
ausdrücklich zu setzen ist außerdem das, was gespeicherte Secrets eine
Rotation von SECRET_KEY überleben lässt.
Die Konfiguration lehnt einen nicht gesetzten VAULT_MASTER_KEY außerhalb von local/development ab.
Projekteinstellungen¶
| Variable | Standard | Beschreibung |
|---|---|---|
PROJECT_NAME |
agenticos |
Anzeigename des Projekts |
API_V1_STR |
/api/v1 |
Präfix der API-Version |
DEBUG |
false |
Debug-Modus einschalten (ausführliche Fehler, Auto-Reload) |
ENVIRONMENT |
local |
Eines von: development, local, staging, production |
TIMEZONE |
UTC |
IANA-Zeitzone (z. B. UTC, Europe/Warsaw, America/New_York) |
MODELS_CACHE_DIR |
./models_cache |
Verzeichnis für zwischengespeicherte ML-Modelle |
MEDIA_DIR |
./media |
Verzeichnis für hochgeladene Dateien |
MAX_UPLOAD_SIZE_MB |
50 |
Obergrenze für Dokumente der Knowledge Base und die Zahl, aus der sich die Obergrenze für die gesamte Anfrage weiter unten ableitet. Ein Dokument dieser Größe wird in Chunks zerlegt und embedded, nicht am Stück gehalten |
CHAT_MAX_UPLOAD_SIZE_MB |
10 |
Was im Chat angehängt werden darf. Eine eigene Einstellung statt der obigen, weil ein Anhang an einen Agent ohne Workspace vollständig in den Prompt eingesetzt wird — die beiden Oberflächen scheitern bei derselben Größe also unterschiedlich. War fest verdrahtete 10 MiB, die kein Betreiber anheben konnte (#498); der Frontend-Container liest dasselbe CHAT_MAX_UPLOAD_SIZE_MB zur Laufzeit, geben Sie also beiden Containern einen Wert, sonst lehnt der Composer eine Datei ab, die der Server annehmen würde |
EMBED_MAX_UPLOAD_SIZE_MB |
5 |
Was eine fremde Person auf eine Hosted Page hochladen darf. Eine Obergrenze über CHAT_MAX_UPLOAD_SIZE_MB, nie ein Weg daran vorbei |
ML_MAX_UPLOAD_SIZE_MB |
25 |
Was ein Aufruf der ML-Dienste einreichen darf — ein Dokument zum Parsen, einen Scan zum Erkennen, eine Aufnahme zum Transkribieren. Eine eigene Einstellung, weil die Bytes innerhalb einer Anfrage geparst oder an eine Engine geschickt statt abgelegt werden, die Grenze also davon handelt, was ein einzelner synchroner Aufruf belegen darf. Sie liegt bei den 25 MB des Transkriptionsclients, der kleinsten Engine-Grenze hinter dieser Oberfläche |
ML_MAX_CONCURRENT_PARSES |
4 |
Wie viele Dokumente ein Worker für die ML-Dienste gleichzeitig parst. Ein Ratenlimit zählt Starts und sieht nicht, was noch läuft - ohne dies wäre das Minutenkontingent an OCR-Aufrufen ebenso viele Erkennungen gleichzeitig. Darüber wird ein Aufrufer mit Retry-After abgelehnt statt eingereiht |
MEM0_ALLOWED_HOSTS |
[] (empty) |
Hostnamen, auf die ein selbst gehosteter mem0-Memory-Dienst zeigen darf. Eine base_url kommt aus dem Spec eines Agents, ohne Allowlist könnte also ein Builder, der einen geteilten mem0-Key binden (aber nicht lesen) darf, ihn auf den eigenen Server richten und den Key aus dem Request-Header abgreifen. Leer lehnt selbst gehostetes mem0 ab und lässt nur die verwaltete Cloud zu; fügen Sie einen vertrauenswürdigen Hostnamen hinzu, um ein selbst gehostetes Deployment zu erlauben. Siehe Secrets |
BROWSER_CDP_ALLOWED_HOSTS |
[] (empty) |
Hostnamen, an denen ein browsender Agent ein Chromium steuern darf. Eine cdp_url kommt aus dem Spec eines Agents, den jeder mit edit-Recht darauf schreibt - die Adresse ist also mandantenkontrolliert und die Anfrage gehört diesem Deployment. Dieselbe Form, für die MEM0_ALLOWED_HOSTS existiert, und der Grund, warum hier eine Allowlist steht und nicht der SSRF-Schutz, durch den jede andere mandantengelieferte URL geht: Der lässt nur öffentliche Adressen zu, lehnt damit den isolierten Browser-Dienst im eigenen Netz des Deployments ab, den die Capability zu betreiben verlangt, und akzeptiert einen ins Internet gestellten CDP-Debugger, was schlechter ist. Der Abgleich ist exakt und ohne Rücksicht auf Groß- und Kleinschreibung, ohne Globs. Leer lehnt Browser-Automatisierung vollständig ab |
BROWSER_SIGN_IN_CDP_URL |
"" (empty) |
Der Browser, mit dem sich eine Person unter Einstellungen → Anmeldungen bei einer Website anmeldet, wo kein Agent einen nennt - der compose-Dienst browser ist http://browser:9222. Sein Host muss in BROWSER_CDP_ALLOWED_HOSTS stehen. Die Website muss eine öffentliche Adresse haben, und nur die Person, die ihn geöffnet hat, darf zusehen. Leer blendet die Option aus |
VISUAL_RENDER_CDP_URL |
"" (leer) |
Der Browser, mit dem ein Visual zu einem Bild gezeichnet wird, wo kein Rahmen es ausführen kann - eine Antwort in Slack, Telegram oder Mattermost und das Dashboard-Vorschaubild einer Seite. Der compose-Dienst browser ist http://browser:9222; sein Host muss in BROWSER_CDP_ALLOWED_HOSTS stehen. Die Zeichnung erreicht von dort nichts: Jede ihrer Anfragen beantwortet das Deployment oder lehnt sie ab. Leer schickt einer Chat-App die Antwort ohne Bild |
BROWSER_EGRESS_PROXY |
"" (empty); compose setzt http://browser-egress:3128 |
Der Proxy, über den der Browser der Installation jede Website erreicht. Der compose-Dienst browser-egress ist http://browser-egress:3128 (docker compose --profile browser up -d browser-egress): Er löst jeden Host selbst auf, lehnt private, Loopback-, Link-Local- und Metadaten-Adressen ab und verbindet sich mit der geprüften Adresse, sodass weder eine Weiterleitung noch DNS-Rebinding eine Seite ins Netz bringt. Genutzt von jedem Durchlauf ohne Proxy der Organisation und nicht auf einem gehosteten Browser. Ein internes Werkzeug, das ein Agent besuchen soll, braucht eine Ausnahme in docker/browser-egress/squid.conf. Leer schickt den Browser direkt hinaus: Setze BROWSER_EGRESS_PROXY= in der Root-.env nur für einen Browser außerhalb von compose, der den Proxy nicht erreicht. Ohne Proxy wird jede Website bei ihrer ersten Anfrage in einem Tab geprüft, danach löst der Browser ihren Namen selbst auf; ein Name, der später im selben Tab auf eine private Adresse zeigt, wird also nicht erkannt |
MCP_ALLOWED_HOSTS |
[] (empty) |
Hosts, die die URL einer MCP-Connection nennen darf, obwohl sie auf eine private oder Loopback-Adresse auflösen, zum Beispiel ein Compose-Dienst: ["postgres-mcp"]. Der Vergleich ist exakt und ignoriert Groß- und Kleinschreibung. Ein gelisteter Host überspringt nur die Adressprüfung: Die URL braucht weiterhin http oder https. Bei einem gelisteten Server kann man sich per OAuth anmelden: Seine Discovery, Registrierung und Token-Anfragen dürfen die gelisteten Hosts erreichen, gepinnt, und die Anmeldung bei einem öffentlichen Server erreicht keinen davon. Jedes Konto des Deployments erreicht jeden Port eines gelisteten Hosts, listen Sie also nur einen Server, der ein Token prüft. Ein Eintrag für einen Cloud-Metadatendienst, eine Link-Local- oder eine Loopback-Adresse wie 169.254.169.254 oder localhost wird beim Start abgelehnt. Leer lehnt jede interne Adresse ab. Siehe eine Connection |
FILE_IO_MAX_WORKERS |
8 |
Größe des eigenen Thread-Pools, der blockierende Dateiarbeit ausführt — das Parsen eines Uploads und das Lesen oder Schreiben seiner Bytes. Bewusst außerhalb des gemeinsamen Default-Executors von asyncio, der auch bcrypt und DNS für gepinnte Hosts ausführt, damit eine Welle von Uploads Anmeldung und ausgehende Anfragen nicht dahinter warten lässt (#1108). Heben Sie ihn auf einem Host an, der viele Uploads gleichzeitig parst. Muss eine positive ganze Zahl sein — eine 0 oder ein negativer Wert wird beim Start abgelehnt |
CHAT_CONVERT_TIMEOUT_SECONDS |
60 |
Wie lange eine einzelne DOC-zu-Text-Konvertierung durch LibreOffice laufen darf, bevor sie gekillt wird. Weit unter den 600s der Wissensdatenbank, weil dies ein interaktiver Upload ist |
CHAT_CONVERT_MAX_CONCURRENCY |
2 |
Wie viele LibreOffice-Konvertierungen gleichzeitig laufen dürfen. Der Subprozess umgeht FILE_IO_MAX_WORKERS, daher wird er hier getrennt begrenzt |
CHAT_CONVERT_KILL_GRACE_SECONDS |
5 |
Die Wartezeit zwischen TERM und KILL, wenn eine Konvertierung zwangsweise gestoppt wird |
CHAT_CONVERT_OUTPUT_MAX_BYTES |
20971520 (20 MiB) |
Obergrenze für die Ausgabedatei einer Konvertierung, geprüft bevor sie zurückgelesen wird |
CHAT_TIFF_MAX_INLINE_PAGES |
10 |
Wie viele Seiten eines mehrseitigen TIFF in PNG umgewandelt und dem Modell gezeigt werden |
CHAT_IMAGE_MAX_PIXELS |
40000000 (~40 MP) |
Pixel-Obergrenze pro Bild, geprüft bevor eine TIFF-Seite dekodiert wird — ein Schutz gegen Dekompressionsbomben |
CHAT_ARCHIVE_MEMBER_MAX_BYTES |
52428800 (50 MiB) |
Obergrenze der entpackten Größe pro Member einer ZIP-basierten Office-Datei (ODF, PPTX) |
CHAT_ARCHIVE_TOTAL_MAX_BYTES |
104857600 (100 MiB) |
Obergrenze der gesamten entpackten Größe einer ZIP-basierten Office-Datei |
CHAT_ARCHIVE_MAX_MEMBERS |
2000 |
Obergrenze der Member-Anzahl einer ZIP-basierten Office-Datei |
CHAT_PARSED_TEXT_MAX_CHARS |
1000000 |
Obergrenze für gespeicherten extrahierten Text, damit ein kleines ZIP/OLE die Zeile nicht aufbläht |
CHAT_PROMPT_TEXT_MAX_CHARS |
200000 |
Obergrenze pro Datei für den in einen Prompt ohne Workspace eingefügten geparsten Text |
CHAT_TURN_TEXT_MAX_CHARS |
500000 |
Gesamtobergrenze für Anhangstext über eine Runde |
DEFAULT_ORG_MONTHLY_BUDGET_USD |
100 |
Die monatliche Ausgabenobergrenze, mit der eine neue Organisation startet, in USD, damit sie nicht einen entlaufenen Agent von einer überraschenden Rechnung entfernt ist. Gilt nur bei der Erstellung; bestehende Organisationen bleiben unberührt, und jede Organisation lässt sich danach wieder auf kein Cap zurücksetzen. Muss positiv sein; lassen Sie den Wert leer, damit Organisationen ohne Cap starten (die ältere Opt-in-Haltung) |
Für Bilder in Produktionskanälen setzen Sie VISUAL_RENDER_CDP_URL=http://visual-render:9222 und BROWSER_CDP_ALLOWED_HOSTS=["visual-render"] in backend/.env. scripts/deploy.sh aktiviert dann das Profil visuals in docker-compose-prod.yml; beim manuellen Start ergänzen Sie --profile visuals.
Öffentliches Browsen benötigt einen separaten Browser: setzen Sie BROWSER_SIGN_IN_CDP_URL=http://browser:9222, BROWSER_EGRESS_PROXY=http://browser-egress:3128 und BROWSER_CDP_ALLOWED_HOSTS=["browser","visual-render"]. Das Deployment aktiviert das Profil browser. Setzen Sie browser_use.cdp_url des Agents auf http://browser:9222 und veröffentlichen Sie seinen Entwurf. Nur der Proxy erlaubt ausgehenden Verkehr; Squid verweigert private und Metadaten-Adressen. Behalten Sie VISUAL_RENDER_CDP_URL=http://visual-render:9222 für isoliertes Zeichnen. Ohne Domain-Einschränkung sind öffentliche Seiten erlaubt; eine ausdrückliche Domain-Liste gilt weiterhin.
Der Renderer veröffentlicht keinen Port, hat keinen Internetzugang, bedient API und Worker und ist auf 1 CPU und 1 GiB RAM begrenzt. Veröffentlichte Backend-Images enthalten das Extra browser; eigene Images benötigen EXTRAS="--extra browser". Schlägt das Rendern fehl, erklärt die Kanalantwort ausdrücklich, dass das Bild nicht angehängt werden konnte. Jeder Rendervorgang erlaubt höchstens 256 Anfragen und 32 ausstehende Antworten; Kartenkacheln teilen zusätzlich RATE_LIMIT_MAP_TILES_PER_MINUTE zwischen Renderern.
Die Größe einer Anfrage, im Unterschied zur Größe einer Datei¶
Jede Grenze oben misst Bytes, die bereits angekommen sind. FastAPI parst einen
Multipart-Body, um den Parameter UploadFile aufzulösen, bevor der Handler
läuft — wenn eines dieser Caps mit len(data) verglichen wird, ist der Body also
längst in eine temporäre Datei geschrieben und in den Speicher gelesen worden.
Hinter einer Session ist das kaum ein Risiko; auf
POST /api/v1/embed/{key}/files, das eine fremde Person mit einem Link erreichen
kann, schon.
Eine Anfrage, die eine Content-Length größer als MAX_UPLOAD_SIZE_MB plus
5 MiB Zuschlag für den Multipart-Umschlag angibt, wird deshalb mit 413
beantwortet, bevor ihr Body gelesen wird. Es gibt keine Einstellung dafür: Sie
folgt MAX_UPLOAD_SIZE_MB, weil eine zweite Zahl, die mit der ersten Schritt
halten muss, eine Zahl ist, die irgendwann darunter liegt.
Es ist die billige Hälfte der Antwort, nicht die ganze. Content-Length
setzt die aufrufende Seite, und eine Chunked-Anfrage gibt gar keine an — die
werden durchgelassen und durch die Caps je Route begrenzt, die echte Bytes
messen. Ein Deployment, das die Garantie statt der Höflichkeit will, setzt
client_max_body_size (nginx) oder das Äquivalent an dem, was seine Verbindungen
terminiert; die Compose-Dateien starten uvicorn ohne eine eigene solche Grenze.
Authentifizierung¶
JWT¶
| Variable | Standard | Beschreibung |
|---|---|---|
SECRET_KEY |
(insecure default) | Signierschlüssel für JWT. Muss in der Produktion geändert werden. Erzeugen mit: openssl rand -hex 32 |
ACCESS_TOKEN_EXPIRE_MINUTES |
30 |
Lebensdauer des Access Tokens |
REFRESH_TOKEN_EXPIRE_MINUTES |
10080 |
Lebensdauer des Refresh Tokens (7 Tage) |
REFRESH_REUSE_GRACE_SECONDS |
60 |
Wie lange nach einer Rotation der verbrauchte Refresh Token noch beantwortet wird, jedes Mal mit demselben Nachfolger, damit eine verlorene Antwort oder eine Serie von Refreshes die Session nicht beendet; 0 schaltet es ab |
ALGORITHM |
HS256 |
Signaturalgorithmus für JWT |
Prüfung für die Produktion: SECRET_KEY muss mindestens 32 Zeichen lang sein und
darf bei ENVIRONMENT=production nicht der Standardwert sein.
Secret Vault¶
Jede Zugangsinformation, die die Plattform dauerhaft speichert — Provider-Keys,
Bot-Tokens für Kanäle, MCP-Credentials und Organisations-Secrets —, wird von
app/core/vault.py versiegelt, dessen Umschlag aus dem Master Key und dem
Eigentümer abgeleitet ist (einer Organisation oder dem Mitglied, dem eine
persönliche Verbindung gehört). Ein Ciphertext ist deshalb außerhalb des Tenants,
für den er versiegelt wurde, nutzlos.
| Variable | Standard | Beschreibung |
|---|---|---|
VAULT_MASTER_KEY |
(empty, falls back to SECRET_KEY) |
Master Key für den Secret Vault — Kurzform für Version 1 von VAULT_MASTER_KEYS. Außerhalb von local/development erforderlich (sofern die Map unten nicht gesetzt ist), damit ein Staging-Vault nicht unter dem veröffentlichten Standardwert von SECRET_KEY versiegelt starten kann. Erzeugen mit: openssl rand -hex 32 |
VAULT_MASTER_KEYS |
{} |
Jeder noch genutzte Master Key, nach Version, als JSON — {"1": "<old>", "2": "<new>"}. Die höchste Version versiegelt neue Secrets; ältere halten bestehende Zeilen lesbar, bis agenticos cmd vault-rotate sie neu einpackt. Ist sie gesetzt, ist sie die ganze Wahrheit: VAULT_MASTER_KEY muss dann leer sein. Siehe Secrets |
API Key¶
| Variable | Standard | Beschreibung |
|---|---|---|
API_KEY |
change-me-in-production |
Gemeinsamer API Key für den programmatischen Zugriff |
API_KEY_HEADER |
X-API-Key |
Name des HTTP-Headers für den API Key |
Prüfung für die Produktion: API_KEY darf bei ENVIRONMENT=production nicht der
Standardwert sein.
OAuth2 (Google)¶
| Variable | Standard | Beschreibung |
|---|---|---|
GOOGLE_CLIENT_ID |
(empty) | Google-OAuth2-Client-ID — Anmeldung und die Einwilligung für den Gmail-Trigger |
GOOGLE_CLIENT_SECRET |
(empty) | Google-OAuth2-Client-Secret |
GOOGLE_REDIRECT_URI |
http://localhost:8000/api/v1/oauth/google/callback |
Callback-URL für OAuth2 |
FRONTEND_URL |
http://localhost:3000 |
Frontend-URL für die OAuth2-Weiterleitungen |
DESKTOP_DEEP_LINK_SCHEME |
agenticos |
Das Schema, das die Desktop-Hülle für eine an den Systembrowser übergebene Anmeldung registriert (Desktop). Der Callback baut daraus eine Weiterleitung, also eine Einstellung und nichts, was ein Aufrufer wählen kann |
So kommen Sie an das Paar: Google Cloud console → APIs & Services → Credentials → Create OAuth client ID → Web application.
Die autorisierte Redirect-URI ist der Callback des Backends, nicht der des
Frontends — standardmäßig http://localhost:8000/api/v1/oauth/google/callback
und in einem Deployment das, was GOOGLE_REDIRECT_URI sagt. Google tauscht den
Code mit der API, die den Browser anschließend an FRONTEND_URL weiterschickt.
Stattdessen die Frontend-URL einzutragen ist der Fehler, den es zu benennen
lohnt: Der Consent-Bildschirm funktioniert, und der Callback antwortet mit 404.
Der Browser wird mit einem einmal einlösbaren Code von einer Minute Gültigkeit
weitergeschickt, nie mit den Session-Tokens selbst: Ein Token in einer
Redirect-URL landet in der Adressleiste, im Access-Log des Frontend-Servers und
im Referer der nächsten Anfrage derselben Origin, und das Refresh Token gilt
eine Woche. Das Frontend tauscht den Code Server zu Server unter
POST /api/v1/oauth/exchange gegen das Token-Paar, und diese Route löst ihn
genau einmal ein.
Single Sign-on (generisches OIDC)¶
Jeder Identitätsanbieter, der ein Discovery-Dokument veröffentlicht: Microsoft Entra ID, Okta, Keycloak, Auth0, Authentik, Google Workspace über seinen OIDC-Endpunkt. Ein Unternehmen, das dies selbst hostet, betreibt bereits einen und wird für seine Belegschaft keine lokalen Passwörter anlegen — ohne dies werden seine MFA und sein Offboarding zweimal gelöst.
| Variable | Standard | Beschreibung |
|---|---|---|
OIDC_ISSUER |
(leer) | Die Issuer-URL. Leer heißt kein SSO, und der Callback antwortet mit 404 |
OIDC_CLIENT_ID |
(leer) | Der Client, den der Anbieter für dieses Deployment ausgegeben hat |
OIDC_CLIENT_SECRET |
(leer) | Dessen Secret |
OIDC_REDIRECT_URI |
http://localhost:8000/api/v1/oauth/oidc/callback |
Der beim Anbieter registrierte Callback |
OIDC_SCOPES |
openid email profile |
Durch Leerzeichen getrennt. Den eigenen Scope des Anbieters ergänzen, wo er einen für die Claims braucht |
OIDC_VERIFIED_CLAIM |
(leer) | Ein dritter Claim, der als „diese Adresse ist bestätigt“ gilt, für einen Anbieter mit eigenem Namen dafür |
OIDC_GROUPS_CLAIM |
(leer) | Der Claim, der die Gruppen einer Person auflistet, meist groups. Gesetzt, wendet jede Anmeldung die Zuordnungen von Verzeichnisgruppen an; leer lässt Mitgliedschaften unberührt. Zusammen mit LDAP_URL abgewiesen |
Der Issuer ist die einzige URL. Authorization, Token, Userinfo und JWKS kommen
aus <issuer>/.well-known/openid-configuration, das der Anbieter über eine
Schlüsselrotation oder einen Endpunktwechsel hinweg korrekt hält — es gibt also
keine weiteren Adressen, die man subtil falsch eintragen kann. Der Ablauf ist
Authorization Code mit PKCE.
Die Claims werden aus dem ID-Token gelesen und aus dem UserInfo-Endpunkt,
wenn das ID-Token sie nicht trägt. Ein Anbieter darf email und seinen
Verifizierungs-Claim bei UserInfo halten und keinen davon ins Token legen; nur
das Token zu lesen würde einen völlig konformen Anbieter eine Anfrage vor einer
gültigen Identität abweisen.
Zwei Knöpfe im Frontend, dort konfiguriert:
| Variable | Standard | Beschreibung |
|---|---|---|
OAUTH_PROVIDERS |
google |
oidc ergänzen, um den SSO-Knopf zu zeigen; allein oidc heißt nur SSO. ldap ergänzt das Verzeichnisformular und kerberos den Knopf für die Windows-Anmeldung, siehe unten |
OIDC_DISPLAY_NAME |
SSO |
Wie der Knopf den Anbieter nennt: Acme SSO, Okta |
OIDC_ICON |
(leer) | google, github oder microsoft — die Marken, die die Anmeldeseite ohnehin mitbringt. Alles andere zeichnet einen schlichten Schlüssel |
Wo welcher Issuer liegt, und was zu registrieren ist:
| Anbieter | Issuer | Redirect-URI registrieren als |
|---|---|---|
| Entra ID | https://login.microsoftonline.com/<tenant-id>/v2.0 |
Redirect-URI der Plattform Web in der App-Registrierung. openid, email, profile unter API-Berechtigungen erteilen |
| Okta | https://<org>.okta.com (oder /oauth2/<id> eines eigenen Authorization Servers) |
Sign-in-Redirect-URI einer Web-Anwendung |
| Keycloak | https://<host>/realms/<realm> |
Valid Redirect URI eines vertraulichen Clients mit aktiviertem Standard Flow |
Zwei Dinge verlangt die Plattform von jedem Anbieter, auf den sie gerichtet wird:
- Die Adresse muss bestätigt sein. Zwei Claims werden akzeptiert: das
standardisierte
email_verifiedundxms_edovvon Entra ID, das Entra stattdessen sendet —email_verifiedgibt es dort gar nicht, undxms_edovist ein optionaler Claim, den man an der App-Registrierung aktiviert; ein Entra-Tenant, der das nicht getan hat, sendet keinen von beiden, und jede Anmeldung wird abgewiesen.OIDC_VERIFIED_CLAIMbenennt einen dritten für einen Anbieter, der ihn anders nennt. Fehlt er, gilt das als nicht bestätigt: eine unbestätigte Adresse heißt, dass bei diesem Anbieter jeder die Arbeitsadresse eines anderen beanspruchen kann — und die Domain-Erlaubnisliste unten steht darauf, dass eine Adresse etwas bedeutet. - Ein stabiles
sub. Das Konto hängt daran und nicht an der Adresse, sodass jemand, der heiratet oder dessen Domain gekauft wird, die eigene Historie behält — und der nächste Inhaber einer frei gewordenen Adresse sie nicht erbt. Gespeichert wird es mit dem Issuer als Namensraum, denn einsubist nur innerhalb seines Issuers eindeutig: das Deployment auf einen anderen Tenant oder Realm zu richten kann dann kein neues Subjekt in das Konto eines alten anmelden.
Die Registrierungsrichtlinie gilt hier genau wie für das Registrierungsformular:
ein invite_only-Deployment weist eine SSO-Anmeldung von jemandem ab, den
niemand eingeladen hat, und eine Domain-Erlaubnisliste weist eine Adresse
außerhalb davon ab — mit demselben Satz auf der Anmeldeseite. Siehe
Wer sich registrieren darf.
Ist OIDC_GROUPS_CLAIM gesetzt, entscheiden die Gruppen des Anbieters über
Mitgliedschaften: Die Zuordnungen von Verzeichnisgruppen
jeder Organisation nehmen Menschen mit einer Rolle auf und ordnen sie Gruppen zu,
und eine zugeordnete Gruppe lässt eine erste Anmeldung auf einem
invite_only-Deployment so zu, wie es eine Einladung tut. Ein Gruppen-Overage
von Entra ID wird abgewiesen, statt als „keine Gruppen“ gelesen zu werden —
siehe Der Gruppen-Claim über OIDC.
SAML und SCIM sind nicht implementiert. Die meisten Identitätsanbieter, die ein mittelständisches Unternehmen betreibt, sprechen OIDC, und diese Einstellungen sind alles, was sie brauchen. Ein Verzeichnis ohne vorgeschalteten Identitätsanbieter lässt sich direkt nutzen — siehe unten.
Verzeichnisanmeldung (LDAP)¶
Anmeldung mit einem Konto aus Active Directory, OpenLDAP oder FreeIPA, indem als
dieses Konto gebunden wird. Verzeichnisanmeldung und Gruppen
erklärt die Anmeldung, was sie abweist und wie Gruppen gelesen werden. Ein leeres
LDAP_URL schaltet sie ab, und die Route antwortet mit 404.
| Variable | Standard | Beschreibung |
|---|---|---|
LDAP_URL |
(leer) | ldaps://host[:port], oder ldap:// mit StartTLS |
LDAP_START_TLS |
false |
Eine ldap://-Verbindung vor dem Bind hochstufen |
LDAP_ALLOW_PLAINTEXT |
false |
ldap:// ohne StartTLS akzeptieren, was Passwörter im Klartext sendet. Andernfalls beim Start abgewiesen |
LDAP_CA_CERT_FILE |
(leer) | Ein PEM-Bundle, gegen das das Zertifikat des Verzeichnisses geprüft wird, für eine unternehmenseigene CA |
LDAP_BIND_DN / LDAP_BIND_PASSWORD |
(leer) | Das Dienstkonto, mit dem eine Anmeldung sucht. Beide oder keines; keines sucht anonym |
LDAP_USER_BASE_DN |
(leer) | Wo Konten gesucht werden. Pflicht zusammen mit LDAP_URL |
LDAP_USER_FILTER |
trifft uid, sAMAccountName, userPrincipalName oder mail |
Muss {username} enthalten, das vor dem Einsetzen maskiert wird |
LDAP_EMAIL_ATTRIBUTE |
mail |
Die Adresse des Kontos |
LDAP_NAME_ATTRIBUTE |
displayName |
Der Anzeigename des Kontos |
LDAP_ID_ATTRIBUTE |
entryUUID |
Die stabile Kennung, an der das Konto hängt — objectGUID bei Active Directory |
LDAP_GROUP_ATTRIBUTE |
memberOf |
Woraus die Gruppen des Kontos gelesen werden |
LDAP_GROUP_BASE_DN |
(leer) | Stattdessen hier nach Gruppen suchen, für ein Verzeichnis ohne memberOf |
LDAP_GROUP_FILTER |
(member={dn}) |
Die Gruppensuche, mit {dn} oder {username} |
LDAP_TIMEOUT_SECONDS |
10 |
Verbindungs- und Lese-Timeout für jede Anfrage an das Verzeichnis |
Das Frontend zeigt das Formular mit ldap in OAUTH_PROVIDERS und benennt es
mit LDAP_DISPLAY_NAME (Standard LDAP).
Kerberos-Anmeldung¶
Integrierte Windows-Anmeldung über SPNEGO, die den Principal des Tickets über das
Verzeichnis oben auflöst — sie braucht also LDAP_URL und ein Image, das mit dem
Extra kerberos gebaut wurde. Siehe Integrierte Windows-Anmeldung.
| Variable | Standard | Beschreibung |
|---|---|---|
KERBEROS_ENABLED |
false |
Kerberos-Anmeldung anbieten. Ohne LDAP_URL beim Start abgewiesen |
KERBEROS_KEYTAB |
(leer) | Die Keytab mit dem Schlüssel des Dienstes. Leer nutzt die Standard-Keytab (KRB5_KTNAME, sonst /etc/krb5.keytab) |
KERBEROS_SERVICE_PRINCIPAL |
(leer) | HTTP/<api host>@<REALM>. Leer akzeptiert ein Ticket für jeden Principal in der Keytab |
LDAP_KERBEROS_FILTER |
(userPrincipalName={principal}) |
Wie der Principal im Verzeichnis gefunden wird: {principal} ist user@REALM, {username} der Teil vor dem @ |
Das Frontend zeigt den Knopf mit kerberos in OAUTH_PROVIDERS und benennt ihn
mit KERBEROS_DISPLAY_NAME (Standard Kerberos).
Datenbank (PostgreSQL)¶
| Variable | Standard | Beschreibung |
|---|---|---|
POSTGRES_HOST |
localhost |
PostgreSQL-Host |
POSTGRES_PORT |
5432 |
PostgreSQL-Port |
POSTGRES_USER |
postgres |
PostgreSQL-Benutzer |
POSTGRES_PASSWORD |
(empty) | PostgreSQL-Passwort |
POSTGRES_DB |
agenticos |
Name der Datenbank |
POSTGRES_SSLMODE |
(empty) | Die Verbindung verschlüsseln: require, verify-ca oder verify-full. Leer heißt Klartext. Siehe Verschlüsselte Verbindungen |
DB_POOL_SIZE |
5 |
Größe des Connection Pools |
DB_MAX_OVERFLOW |
10 |
Maximale Overflow-Verbindungen |
DB_POOL_TIMEOUT |
5 |
Sekunden, die eine Anfrage auf eine freie Verbindung wartet, bevor sie mit 503 und Retry-After beantwortet wird. Absichtlich kurz: Eine Anfrage, die 30 s wartete, war ohnehin verloren, und die Schlange der Wartenden hielt den Pool nach einer Lastspitze voll |
Berechnete Eigenschaften:
- DATABASE_URL -- asynchroner Connection String (postgresql+asyncpg://...)
- DATABASE_URL_SYNC -- synchroner Connection String für Alembic
Redis¶
Die Compose-Dateien führen Valkey (valkey/valkey:8-alpine) aus, den
BSD-3-Clause-Fork von Redis 7.2, und nicht Redis selbst, das seit 7.4.0 unter
RSALv2 oder SSPL-1.0 steht - keine von beiden ist eine Open-Source-Lizenz
(Lizenzen). Valkey spricht dasselbe Protokoll auf demselben Port,
sodass die Einstellungen unten, das redis://-Schema und der Dienstname redis
unverändert bleiben, und ein Deployment, das diese stattdessen auf ein verwaltetes
Redis, Valkey oder Elasticache richtet, funktioniert genau wie zuvor.
Es läuft als Cache und sonst nichts: --save '', kein Volume, also startet es nach
jedem Neustart leer. Alles, was die Plattform hier ablegt - Rate-Limit-Buckets,
Dedupe-Claims der Kanäle, Mitgliedschaftsantworten - trägt eine TTL und baut sich
selbst wieder auf.
Ein Snapshot brachte nichts und kostete einen Ausfall: Valkey 8
ist ein Fork von Redis 7.2 und verweigert eine von Redis 7.4 geschriebene RDB, also
geriet das erste Deployment auf einen Host, auf dem redis:7-alpine gelaufen war,
in eine Crash-Loop und nahm jeden Dienst mit, der auf den Cache wartet. Eine
verwaltete Instanz, die doch persistiert, ist in Ordnung; die Plattform verlässt
sich so oder so nicht darauf.
| Variable | Standard | Beschreibung |
|---|---|---|
REDIS_HOST |
localhost |
Redis-Host |
REDIS_PORT |
6379 |
Redis-Port |
REDIS_PASSWORD |
(none) | Redis-Passwort (optional) |
REDIS_DB |
0 |
Nummer der Redis-Datenbank |
REDIS_SSL |
false |
Die Verbindung verschlüsseln (rediss://). Siehe Verschlüsselte Verbindungen |
Verschlüsselte Verbindungen (TLS)¶
Beide Stores verbinden sich standardmäßig im Klartext. Auf einem einzelnen Host mit Postgres und Redis im selben Docker-Netz ist das in Ordnung, und genau so laufen die mitgelieferten Compose-Dateien. Bei einem verwalteten Postgres oder einem Redis auf einem anderen Knoten ist die verschlüsselte Verbindung die Maßnahme zur Übertragungssicherheit, nach der eine prüfende Person zuerst fragt (HIPAA §164.312(e), SOC 2 CC6.7).
POSTGRES_SSLMODE zu setzen baut die URL, die der jeweilige Treiber versteht —
?ssl=<mode> für das asyncpg der Anwendung, ?sslmode=<mode> für das psycopg2
von Alembic —, und REDIS_SSL stellt das Redis-Schema auf rediss:// um.
require verschlüsselt die Verbindung; verify-ca und verify-full prüfen
zusätzlich das Zertifikat des Servers.
REDIS_SSL verlangt außerdem eine gültige Zertifikatskette und einen passenden
Hostnamen an der URL selbst, statt beides den Voreinstellungen von redis-py zu
überlassen.
Eine private CA ist eine Datei, die die Treiber lesen, nicht der Trust Store des Betriebssystems
Keiner der beiden Treiber fragt den Trust Store des Containers, und das Image
läuft als Benutzer ohne Root-Rechte, ohne einen Entrypoint, der ihn neu bauen
könnte. asyncpg und libpq lesen beide die CA-Datei, die PGSSLROOTCERT
nennt; redis-py vertraut dem Bundle, auf das OpenSSL zeigt, und das
überschreibt SSL_CERT_FILE. Hängen Sie die CA einmal ein und setzen Sie
beide Variablen darauf — verify-ca und verify-full scheitern ohne die
erste, weil asyncpg dann nach ~/.postgresql/root.crt sucht und nichts findet.
Jeder Dienst, der eine Verbindung zu einem Store öffnet, braucht die Änderung
app, migrate und worker verbinden sich jeweils mit Postgres und
Redis, und die mitgelieferten Compose-Dateien pinnen POSTGRES_HOST=db und
REDIS_HOST=redis in der environment jedes einzelnen, was eine Env-Datei
übersteuert. Ein verwalteter Store ist deshalb eine Override-Datei, die alle
drei erreicht, und keine Zeile in .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
Die mitgelieferten Dienste db und redis starten weiter, ungenutzt;
agenticos cmd doctor zeigt, welchen Store jede Verbindung tatsächlich erreicht
hat und ob sie verschlüsselt war (postgres: tls=on/off, redis: tls=on/off,
aus pg_stat_ssl und dem URL-Schema).
E-Mail (SMTP)¶
Das Deployment verschickt Post über einen SMTP-Server, und eines ohne konfigurierten Server scheitert nicht — es läuft, und jeder Ablauf, der auf Post angewiesen ist, hört still auf, ohne dass einer davon es ankündigt:
- Anmeldung ohne Passwort und Passwort-Zurücksetzungen — die Magic-Link- und Reset-Mails sind die Self-Service-Wege in ein Konto;
- Einladungen — eine eingeladene Adresse bekommt nie eine Mail (die Konsole sagt das inzwischen, statt zu behaupten, sie habe eine verschickt, #1484);
- Benachrichtigungen — eine Budget-Überschreitung, eine Freigabeanfrage, ein Nutzungsbericht, der Hinweis, der verschickt wird, wenn ein Administrator im Namen eines anderen Kontos handelt.
| Variable | Standard | Beschreibung |
|---|---|---|
SMTP_HOST |
localhost |
Host des SMTP-Servers |
SMTP_PORT |
587 |
Port des SMTP-Servers. 587 und 25 handeln STARTTLS aus; 465 öffnet TLS von Anfang an |
SMTP_USER |
(empty) | Benutzername, mit dem sich das Relay authentifiziert, zusammen mit SMTP_PASSWORD. Für ein Relay ohne Authentifizierung leer lassen |
SMTP_PASSWORD |
(empty) | Passwort zu diesem Benutzernamen |
SMTP_TLS |
true |
Ob die Verbindung verschlüsselt wird. Der Port wählt das Schema — STARTTLS auf 587, implizites TLS auf 465 —, sofern SMTP_TLS_MODE nichts anderes sagt. Setzen Sie false nur für ein unverschlüsseltes Relay, etwa einen lokalen Server auf 25 |
SMTP_TLS_MODE |
auto |
Wie die verschlüsselte Verbindung geöffnet wird. auto lässt den Port entscheiden; implicit öffnet TLS ab dem ersten Byte und starttls handelt das Upgrade aus, unabhängig vom Port. Wird bei SMTP_TLS=false ignoriert |
EMAIL_FROM |
noreply@agenticos.com |
Die From-Adresse jeder Nachricht |
EMAIL_FROM_NAME |
agenticos |
Der Anzeigename, der neben dieser Adresse steht |
Wie die Verbindung verschlüsselt wird
SMTP_TLS ist der Ein-/Ausschalter; das Schema wählt der Port. Die
ausgelieferte Voreinstellung — 587 mit SMTP_TLS=true — handelt STARTTLS
aus, und das erwartet ein standardkonformer Submission-Server. Nutzen Sie
465 für einen Server, der stattdessen implizites TLS will, und
SMTP_TLS=false auf 25 für ein Relay im Klartext.
Ein Server, der implizites TLS auf einem anderen Port als 465 spricht — etwa
8465 —, braucht SMTP_TLS_MODE=implicit, denn auto böte ihm einen
Handshake im Klartext an und jeder Versand würde scheitern. starttls ist der
spiegelbildliche Fall.
Hintergrundarbeit¶
Jobs warten in PostgreSQL und laufen in einem Worker, python -m app.worker,
aus demselben Image wie die API. Es gibt keinen Server, auf den er zeigen muss,
und keinen Schlüssel: Der Worker erreicht dieselbe Datenbank wie die API. Jede
Einstellung unten gilt pro Worker-Prozess.
| Variable | Standard | Beschreibung |
|---|---|---|
WORKER_POOLS |
agent,ingest,system |
Die Pools, die dieser Prozess bedient. Ein zweiter Worker mit ingest legt das Parsen auf einen eigenen Host |
WORKER_AGENT_CONCURRENCY |
10 |
Runs von Agents und ausgelöste Trigger gleichzeitig |
WORKER_SYSTEM_CONCURRENCY |
4 |
Aufräumläufe, Bereinigungen und Berichte gleichzeitig |
WORKER_INGEST_CHILDREN |
2 (1 in den mitgelieferten Compose-Dateien) |
Kindprozesse für Parsen und Embeddings, jeder mit einem Job zur Zeit |
WORKER_INGEST_MEMORY_MB |
1536 |
Wie viel ein Kindprozess samt allem, was er gestartet hat, halten darf, bevor der Watchdog ihn stoppt |
WORKER_INGEST_MAX_JOBS |
100 |
Jobs, die ein Kindprozess ausführt, bevor er ersetzt wird |
WORKER_LEASE_SECONDS |
60 |
Wie lange ein abgeholter Job ohne Heartbeat gehalten wird |
WORKER_BREAKER_DEATHS |
5 |
Tode des Workers in Folge, über die Jobs einer Art, die diese Art pausieren |
WORKER_HEALTH_FILE |
/tmp/agenticos-worker-alive |
Die Datei, die die Probe des Containers liest |
WORKER_MEMORY_LIMIT |
4g, 2560M im Produktions-Compose |
Das eigene Speicherlimit des Containers - eine Compose-Variable, keine Einstellung |
BACKGROUND_JOB_RETENTION_DAYS |
14 |
Beendete Jobs, die für die Seite Background jobs aufbewahrt werden |
Planen Sie den Speicher nach den Kindprozessen, nicht nach der Zahl der
Jobs. Ein Worker hält etwa 450 MB für seine importierte Anwendung, jeder
Ingestion-Kindprozess noch einmal so viel plus das Dokument, das er parst.
WORKER_INGEST_MEMORY_MB liegt absichtlich unter dem Limit des Containers: Der
Watchdog stoppt einen Kindprozess, der darüber wächst, und schreibt den Grund an
den Job, wo der OOM-Killer des Kernels nur einen toten Prozess hinterließe. Auf
einem Host mit freiem Speicher erhöhen Sie WORKER_INGEST_CHILDREN und das
Limit des Containers mit.
Ein Job, der seinen Prozess umbringt, kann sich nicht im Kreis drehen. Sein
Versuch wird beim Abholen gezählt, ein Kindprozess, den der Kernel mitten im
Parsen tötet, hat also einen verbraucht, und nach max_attempts ist der Job
tot. Eine Art, deren Jobs ihren Worker WORKER_BREAKER_DEATHS-mal in Folge
umbringen, wird pausiert, und jeder Eigentümer des Deployments erfährt es;
nichts holt sie ab, bis einer von ihnen sie auf der Seite Background jobs
fortsetzt. Gezählt wird je Organisation: Ein Mandant, dessen Jobs ihren Worker
immer wieder umbringen, pausiert die Art nur für sich, und Jobs ohne Organisation
teilen sich einen Zähler für das ganze Deployment.
Was ein Job hinterlässt, wird geschlossen, auch wenn das einmal scheitert.
Ein Job, der endgültig endet, kann ein Dokument in processing oder ein
Sync-Protokoll in running hinterlassen. Der Worker schließt sie, wenn der Job
endet, und der Rescuer wiederholt ein gescheitertes Schließen - etwa bei
unerreichbarer Datenbank - einen Tag lang.
Ein Worker, der stirbt, gibt seine Jobs innerhalb von WORKER_LEASE_SECONDS
zurück: Die Rettung eines anderen Workers übernimmt den abgelaufenen Lease und
schreibt den Job als Tod eines Workers auf. Ein Deploy kostet einen Job keinen
Versuch - der Worker hört auf abzuholen, lässt laufende Jobs 30 Sekunden lang
fertig werden und gibt den Rest zurück. Die Compose-Dateien geben ihm 45
Sekunden, bevor sie ihn beenden.
Der Health-Check des Containers liest WORKER_HEALTH_FILE, die der Heartbeat
nach jedem Schlag berührt, der die Datenbank erreicht hat, sodass ein Worker,
der lebt, aber von Postgres abgeschnitten ist, als unhealthy gilt. Es ist eine
Datei und kein HTTP-Endpunkt, weil der Worker nichts bedient.
In backend/Dockerfile gibt es kein HEALTHCHECK. Das Image wird als
verschiedene Prozesse gestartet - die API, der Worker und die Migrationen -, und
eine Probe für den einen wäre ein dauerhafter Fehlalarm für den anderen, also
trägt jede Service-Definition ihre eigene.
Ablauf einer Freigabe¶
| Variable | Standard | Beschreibung |
|---|---|---|
APPROVAL_EXPIRY_HOURS |
72 |
Wie lange ein geparkter Tool-Aufruf wartet, bevor der stündliche Durchlauf ihn per Timeout ablehnt |
QUESTION_EXPIRY_HOURS |
24 |
Wie lange ein Run auf die Antwort auf eine Frage des Agents wartet, bevor derselbe Durchlauf ihn beendet |
Drei Tage, weil es ein Wochenende überspannen muss: Die Freigabe, die am Freitagnachmittag eintrifft, ist die, über die niemand entscheidet, und sie am Samstag ablaufen zu lassen hieße, sie dafür ablaufen zu lassen, dass sie zur falschen Stunde gestellt wurde. Verkürzen Sie ihn, wo eine Warteschlange während der Arbeitszeit beobachtet wird und eine veraltete Anfrage schlimmer ist als eine langsame; verlängern Sie ihn, wo Freigaben ein wöchentliches Ritual sind. Einen Aufruf ablaufen zu lassen beendet auch seinen Run — siehe Governance dazu, was das klärt und was es bewusst unangetastet lässt.
Abräumen hängen gebliebener Runs¶
| Variable | Standard | Beschreibung |
|---|---|---|
RUN_HEARTBEAT_SECONDS |
15 |
Wie oft der Prozess, der einen Run ausführt, ihn als lebendig stempelt, während sein Modell arbeitet |
STALE_RUN_REAPED_AFTER_SECONDS |
120 |
Wie lange ein Run auf running ohne Lebenszeichen bleiben darf, bevor der minütliche Durchlauf entscheidet, dass sein Prozess gestorben ist, und ihn als failed beendet. Mindestens dreimal RUN_HEARTBEAT_SECONDS, was der Start prüft; null oder darunter schaltet den Durchlauf ab |
Die Zeile eines Runs wird committet, bevor sein Modell aufgerufen wird, ein
mitten im Run getöteter Worker lässt sie also auf running zurück, ohne dass
etwas sie noch abschließt. Die Obergrenze muss nicht genau sein — ein lebender
Run, den der Durchlauf trotzdem umstellt, wird durch seinen eigenen abschließenden
Schreibvorgang zurückgestellt —, setzen Sie sie also deutlich über Ihren längsten
legitimen Run und nicht knapper. Siehe
Governance.
Ein verlassener Chat-Turn¶
| Variable | Standard | Beschreibung |
|---|---|---|
CHAT_DETACHED_TURN_MINUTES |
60 |
Wie lange ein Chat-Turn weiterläuft, nachdem die fragende Person gegangen ist - geschlossener Tab, beendete App, abgebrochene Verbindung -, bevor er gestoppt wird; höchstens 120, denn danach zeigt die Gesprächsliste den Turn nicht mehr als laufend |
Die Antwort landet in der Unterhaltung und ihre Person wird benachrichtigt; der Wert begrenzt also die Ausgaben für eine Antwort, die vielleicht nie gelesen wird, statt etwas abzuschalten. Er muss Ihren längsten Recherche- oder Browser-Turn abdecken; ein Run bleibt ohnehin an das Budget seines Agenten gebunden. Siehe Konsole.
KI-Modelle — in der App konfiguriert, nicht hier¶
Chat-Modelle sind keine Umgebungsvariablen. Jede Organisation legt ihre eigenen
Provider-Keys im Vault ab (Settings → Models), und der Spec jedes Agents nennt
das Model Profile, auf dem er läuft. AI_MODEL, AI_TEMPERATURE,
AI_THINKING_ENABLED, AI_THINKING_EFFORT, AI_AVAILABLE_MODELS,
AI_FRAMEWORK und LLM_PROVIDER wurden zusammen mit dem allgemeinen Assistenten
der Vorlage entfernt; sie zu setzen bewirkt heute nichts.
Die eine Modell-Zugangsinformation, die in der Umgebung bleibt, ist der Key für die Embeddings — siehe RAG weiter unten.
Observability (Logfire)¶
Optional und aus, bis ein Token gesetzt ist. Nichts in diesem Abschnitt muss
konfiguriert werden: ohne LOGFIRE_TOKEN läuft die Plattform vollständig, und ein
Run hält seine eigene Trace-Id weiterhin lokal fest. Was ein Token bringt, sind
die Traces, und ein Trace ist eine Kopie des Runs — die Nachricht des Nutzers, die
Ausgabe des Modells und jedes Tool-Argument samt Ergebnis —, denn genau das
aufzuzeichnen ist die Aufgabe eines Observability-Backends. Das Token zu setzen
ist damit eine Entscheidung darüber, wohin Run-Inhalte gehen dürfen, nicht nur
über Dashboards. Ein Deployment über Gesundheits-, Rechts- oder HR-Daten liest
vorher, was das Deployment verlässt.
Drei Dinge können Runs auf ein Projekt richten, und sie überlagern sich:
LOGFIRE_TOKENhier instrumentiert Pydantic AI global, jeder Run exportiert also in das Projekt des Deployments selbst. Zwei Prozesse konfigurieren es: die API beim Start (app/main.py) und der Worker, einmal beim Start (app/worker/__main__.py), da er den Lifespan der API nie durchläuft. Die Spans des Workers tragen<Dienstname>-worker, ein Projekt kann einen langsamen geplanten Run also von einer langsamen Chat-Runde unterscheiden.- Eine Umgebung kann ein eigenes Schreib-Token tragen, im Vault versiegelt, das die an sie gebundenen Runs umleitet.
- Der
observability-Block eines Agenten benennt ein eigenes Projekt — meist das eines Kunden.
Der content-Modus des Agenten entscheidet, wie viel jeder Span trägt: full,
der Standard, zeichnet die Nachricht, die Ausgabe und jeden Tool-Aufruf auf;
none nur Zeit, Tokens, Kosten und Tool-Namen. Er wird dort angewandt, wo der
Agent instrumentiert wird, hält also unabhängig davon, welches Token den Run
traced — beim deploymentweiten wird der Agent stattdessen an eine inhaltsfreie
Instrumentierung geheftet, und ein Spezialist, an den der Agent delegiert — inline
geschrieben oder vom Model mitten im Run erfunden —, erbt den Modus. Eine Grenze,
bevor man sich darauf verlässt: das
Anheften dieser Instrumentierung ist Best Effort, ein Fehlschlag wird
protokolliert und der Run läuft weiter.
Mit ENVIRONMENT=production wird kein Span auf die Konsole geschrieben. Der
Konsolen-Exporter von Logfire schreibt eine Zeile pro SQL- und Redis-Aufruf, etwa
vierzig pro Anfrage, in ein Container-Log, das niemand abfragen kann. In der
Produktion existiert ein Span also nur dort, wohin das Token ihn schickt, und ohne
Token hinterlässt eine langsame Anfrage gar keine Spur. agenticos cmd doctor
meldet das in seiner Zeile tracing. Anderswo schreibt die Konsole weiter Spans
für den, der den Server beobachtet.
Gib jedem Deployment sein eigenes LOGFIRE_ENVIRONMENT, etwa local auf dem
Laptop und production auf dem Server. Ein Projekt kann dann beide aufnehmen und
sie trotzdem auseinanderhalten.
| Variable | Standard | Beschreibung |
|---|---|---|
LOGFIRE_TOKEN |
(none) | Token für Pydantic Logfire. Erhältlich unter https://logfire.pydantic.dev |
LOGFIRE_SERVICE_NAME |
agenticos |
Name des Dienstes im Logfire-Dashboard |
LOGFIRE_ENVIRONMENT |
development |
Kennzeichnung der Umgebung |
LOGFIRE_ORGANIZATION |
(none) | Slug der Organisation, um einen Link in einen gespeicherten Trace zu bauen. Das Token ist eine schreibende Zugangsinformation und trägt keinen der beiden Slugs |
LOGFIRE_PROJECT |
(none) | Slug des Projekts, neben dem der Organisation. Ist eines von beiden nicht gesetzt, wird die logfire_trace_id eines Runs weiterhin aufgezeichnet und kein Link angeboten |
LOGFIRE_BASE_URL |
https://logfire-us.pydantic.dev |
Zu welchem Logfire-Deployment diese Slugs gehören. logfire-eu ist ein anderer Host, und ein für den falschen gebauter Link antwortet mit 404 |
Websuche¶
| Variable | Standard | Beschreibung |
|---|---|---|
RAG (Retrieval Augmented Generation)¶
Vektordatenbank¶
pgvector nutzt die bestehende PostgreSQL-Verbindung. Es ist keine zusätzliche
Konfiguration nötig — aber das Image muss pgvector/pgvector:pg16 sein,
worauf jede Compose-Datei hier pinnt.
\"Vector store: unconfigured\" auf einem frischen Deployment ist kein Fehler
Die Extension wird angelegt, sobald zum ersten Mal in eine Collection
geschrieben wird, vor dem ersten Dokument fehlt sie also tatsächlich, und die
Admin-Seite System wie auch agenticos cmd doctor sagen das. Es löst sich mit
der ersten Ingestion von selbst.
Ein Fehler ist dort unhealthy, und es benennt, welcher von dreien: Das Image
liefert kein pgvector mit; die verbindende Rolle darf sie nicht anlegen; oder
das Datenverzeichnis trägt die Zeile der Extension, während dem Image, auf dem
es jetzt läuft, die Bibliothek fehlt. Alle drei lassen einen Upload scheitern,
nachdem die Bytes angenommen wurden, und alle drei lasen sich früher wie ein
gesunder erster Tag
(#1504).
Embeddings¶
Nichts hier. Jede Collection benennt selbst den Provider, über den sie embeddet,
das Model, und entweder den Vault-Schlüssel der Organisation, der dafür zahlt,
oder — beim schlüssellosen Provider ollama — einen lokalen Dienst, eine
Zeile unter Knowledge → Integrations, die sagt, wo das Ollama des Deployments
oder der Organisation antwortet. Siehe Dateiverarbeitung.
Früher standen hier zwei Variablen. EMBEDDING_MODEL wählte für neue Collections
ein Model vor und ist weg: Das Formular bietet die Models an, die der gewählte
Provider bereitstellt. EMBEDDING_OLLAMA_BASE_URL benannte ein Ollama für das
ganze Deployment und ist jetzt ein lokaler Dienst, pro Organisation oder
deploymentweit.
Dokument-Parsing — je Collection konfiguriert, nicht hier¶
Parser, OCR, Chunk-Größe, Chunk-Überlappung, Chunking-Strategie und das Modell
für Bildbeschreibungen sind keine Umgebungsvariablen. Sie liegen auf jeder
Knowledge Base (knowledge_bases.ingestion_config), werden auf /rag bearbeitet,
und jedes einzelne davon lässt sich zusätzlich für einen einzelnen Upload
übersteuern.
Der Grund ist, dass ein installationsweiter Wert dasselbe Formular auf zwei
Deployments unterschiedliche Collections erzeugen ließ, ohne dass im Produkt
etwas zeigte, welche — und ein Archiv gescannter Verträge und ein Ordner mit
Markdown-Notizen wollen auf demselben Deployment unterschiedliche Antworten.
PDF_PARSER, CHAT_PDF_PARSER, LLAMAPARSE_TIER, LITEPARSE_OCR_LANGUAGE,
LITEPARSE_TIMEOUT_SECONDS, RAG_ENABLE_OCR, RAG_CHUNK_SIZE,
RAG_CHUNK_OVERLAP und RAG_CHUNKING_STRATEGY wurden entfernt; sie zu setzen
bewirkt heute nichts.
Zwei Dinge, die früher hier blieben, sind jetzt Zeilen im Produkt. Ein
LlamaParse-Schlüssel ist ein Vault-Eintrag, den die Ingestion-Konfiguration der
Collection benennt (llamaparse_secret_id), und eine Collection auf LlamaParse
ohne einen wird schon am Formular abgelehnt — LLAMAPARSE_API_KEY ist weg. Ein
OCR-Server, an den LiteParse Seiten schickt, ist ein lokaler Dienst der Art ocr
unter Knowledge → Integrations, pro Collection gewählt (ocr_endpoint_id),
registriert vom Betreiber einer Organisation oder — für jede Organisation — vom
Administrator des Deployments; LITEPARSE_OCR_SERVER_URL ist ebenfalls weg.
Keine von beiden war für den Tenant sichtbar, über dessen Dokumente sie
entschied.
Chat-Anhänge werden mit PyMuPDF gelesen und sind nicht konfigurierbar: Ein Anhang gehört zu keiner Collection, es gibt also keine gespeicherte Konfiguration zu lesen.
Google-Drive-Sync¶
| Variable | Standard | Beschreibung |
|---|---|---|
GOOGLE_DRIVE_CREDENTIALS_FILE |
credentials/google-drive-sa.json |
Pfad zu den Zugangsdaten des Google-Service-Accounts, nur für rag-sync-gdrive |
Das ist die Zugangsinformation des CLI, kein Rückfall für eine Sync-Quelle.
Eine gdrive-Sync-Quelle nennt ein gcp_service_account-Secret im Vault ihrer
Organisation und läuft damit oder gar nicht: Ein deploymentweiter Key, der für
einen fehlenden einsprang, führte dazu, dass die folder_id eines Tenants
auswählte, was unter dem Service-Account des Betreibers gelistet war. Die
Zugangsinformation der Quelle ist keine Einstellung und kein Konfigurationsfeld —
siehe Secrets und der Vault.
Die Datei ist der Key eines Service-Accounts: Cloud console → create a service account → Keys → Add key → JSON. Dann teilen Sie den Drive-Ordner mit der E-Mail-Adresse des Service-Accounts selbst — er ist ein Principal wie jeder andere, und ein Ordner, den niemand mit ihm geteilt hat, listet sich als leer statt als abgelehnt.
Hochgeladene Dateien im Ruhezustand¶
Wo Chat-Anhänge, Avatare, Branding-Bilder und die Originale von Wissensdatenbank-Dokumenten liegen. Ein Backend pro Deployment, nie pro Organisation, und ein Wechsel verschiebt nicht, was das andere bereits hält.
| Variable | Standard | Beschreibung |
|---|---|---|
FILE_STORAGE_BACKEND |
local |
local schreibt unter MEDIA_DIR; s3 schreibt in einen S3-kompatiblen Bucket |
FILE_STORAGE_S3_BUCKET |
(leer) | Der Bucket. Beim Backend s3 erforderlich; ohne ihn verweigert die API jeden Upload |
FILE_STORAGE_S3_ENDPOINT |
(keiner) | Leer für AWS. Die Adresse des Dienstes für MinIO oder einen anderen kompatiblen Speicher |
FILE_STORAGE_S3_REGION |
us-east-1 |
AWS-Region |
FILE_STORAGE_S3_ACCESS_KEY |
(leer) | Auf AWS leer lassen: dann antwortet boto3s eigene Credential-Chain, also ein Instanzprofil oder eine IRSA-Rolle statt eines langlebigen Schlüssels in einer Umgebungsdatei |
FILE_STORAGE_S3_SECRET_KEY |
(leer) | Die andere Hälfte, ebenso |
FILE_STORAGE_S3_PATH_STYLE |
false |
true für MinIO und die meisten kompatiblen Speicher, die einen Bucket über den Pfad adressieren. Eine Virtual-Host-Anfrage scheitert dort an DNS statt an S3 |
FILE_STORAGE_S3_PREFIX |
(leer) | Jeder Key, den dieses Deployment schreibt, liegt darunter, so dass ein Bucket mehr als ein Deployment aufnehmen kann, ohne dass sich ihre Keys treffen |
FILE_STORAGE_S3_ENCRYPTION |
sse-s3 |
Worum der Speicher bei jedem Schreibvorgang gebeten wird: sse-s3 (der eigene Schlüssel des Buckets), sse-kms (der Schlüssel unten) oder none |
FILE_STORAGE_S3_KMS_KEY_ID |
(keiner) | Die KMS-Schlüssel-ID oder -ARN. Erforderlich im Modus sse-kms: S3 liest ein unbenanntes aws:kms als seinen eigenen AWS-verwalteten Schlüssel aws/s3 und nicht als den Standardschlüssel des Buckets, leer hieße also Verschlüsselung unter einem Schlüssel, den niemand gewählt hat |
none ist für einen Speicher ohne KMS und ist keine Verschlüsselung
MinIO verweigert SSE-S3, solange kein KES-Server konfiguriert ist, also
braucht ein kompatibler Speicher ohne KMS none, um überhaupt zu
funktionieren — und ein so betriebenes Deployment hat die Verschlüsselung,
die seine Volumes ihm geben, und nicht mehr. agenticos cmd doctor meldet
diese Konfiguration als unkonfiguriert statt als gesund, und die Zeile zum
Ruhezustand in Sicherheit sagt
dasselbe.
Ein Bucket, in den das Deployment schreibt, braucht s3:PutObject,
s3:GetObject, s3:DeleteObject und s3:ListBucket, im Modus sse-kms
zusätzlich kms:Encrypt, kms:Decrypt und kms:GenerateDataKey auf dem
Schlüssel. Ein lokales MinIO startet make docker-minio auf :9000 mit
minioadmin / minioadmin.
Erzeugte Medien¶
Bilder, Sprache und Videos, die Agenten erzeugen, liegen im selben Speicher unter
generated_<org>/, und jedes wird bei seiner Organisation und der Conversation
erfasst, in der es entstand. Was eine Organisation behält, ist doppelt begrenzt.
Was wann gelöscht wird, steht unter
Datenschutz.
| Variable | Standard | Beschreibung |
|---|---|---|
GENERATED_MEDIA_QUOTA_MB |
5120 |
Wie viel erzeugte Medien eine Organisation behalten darf, in MiB. Jede Datei zählt, auch eine ohne Unterhaltung - aus einer Vorschau oder einem Testlauf, einem /btw-Einschub in einem Kanal, einem API-Run, der keine Unterhaltung nennt; der Sweep löscht diese nach 7 Tagen. Geprüft vor jedem bezahlten Erzeugungsaufruf: darüber antwortet das Tool, dass die Organisation ihren Speicher aufgebraucht hat, und gibt nichts aus. Ein zugelassener Aufruf hält eine Schätzung seiner Datei gegen das Kontingent, bis die Datei gespeichert ist - 16 MiB für ein Bild, 8 KiB je Zeichen Sprache, 2 MiB je Sekunde Video -, daher wird ein Aufruf abgelehnt, dessen Schätzung die Organisation darüber bringen würde, und gleichzeitige Läufe oder parallele Aufrufe in einer Antwort können nicht alle mit derselben Zahl durchkommen. Eine Datei, die größer ist als ihre Schätzung, zählt mit ihrer echten Größe. 0 ist kein Kontingent |
GENERATED_MEDIA_RETENTION_DAYS |
90 |
Alter, ab dem der tägliche Job sweep-generated-media eine erzeugte Datei und ihren Eintrag löscht, ob ihre Conversation noch existiert oder nicht. 0 behält sie, bis ihre Conversation oder Organisation gelöscht wird. Eine Datei ohne Conversation geht in jedem Fall nach 7 Tagen |
Eine Datei wird gestreamt ausgeliefert, nur im Bytebereich, den ein Player
anfragt - von der Platte oder per GetObject mit Bereich -, sodass ein langer
Clip weder in der API noch im Server der Konsole je ganz gehalten wird.
S3/MinIO-Sync¶
| Variable | Standard | Beschreibung |
|---|---|---|
S3_RAG_ENDPOINT |
(none) | Endpunkt-URL für S3/MinIO. Eine Sync-Quelle darf sie übersteuern |
S3_RAG_ACCESS_KEY |
(empty) | Access Key, nur für den CLI-Befehl rag-sync-s3 |
S3_RAG_SECRET_KEY |
(empty) | Secret Key, ebenso |
S3_RAG_BUCKET |
agenticos-rag |
Name des Buckets |
S3_RAG_REGION |
us-east-1 |
AWS-Region. Die eigene Region einer Zugangsinformation gewinnt, wo sie eine hat |
Dies ist der Sync-Connector, der einen Bucket liest, der einem Mandanten gehört — nicht der eigene Dateispeicher des Deployments oben. Beide werden getrennt konfiguriert, und zwar mit Absicht: das eine ist Infrastruktur, das andere sind die Daten eines Mandanten.
Das Schlüsselpaar hier gehört dem CLI, nicht einer Sync-Quelle. Eine
s3-Sync-Quelle nennt ein aws_credentials-Secret im Vault ihrer Organisation,
genauso wie eine gdrive-Quelle einen Service-Account nennt. Endpunkt und Region
fallen weiterhin auf diese Einstellungen zurück, weil keines von beiden einen
Principal nennt — sie sagen, wo der Store liegt, nicht, wer fragt.
Veröffentlichte Apps¶
Seiten, die Agents mit der Capability artifacts veröffentlichen. Ihre Bytes
liegen im Dateispeicher oben; diese Einstellungen begrenzen sie und sagen, von wo
sie ausgeliefert werden. Siehe Apps.
| Variable | Standard | Beschreibung |
|---|---|---|
ARTIFACT_MAX_BYTES |
5 MiB | Eine Version einer Seite. Eine Veröffentlichung darüber wird mit einer Meldung abgelehnt, die das Modell liest |
ARTIFACT_MAX_VERSIONS |
20 |
Pro App behaltene Versionen. Die älteste wird entfernt, wenn eine neuere hinzukommt, außer der öffentliche Link ist an sie angeheftet |
ARTIFACT_VIEW_TTL_SECONDS |
300 |
Wie lange sich eine signierte Inhaltsadresse öffnen lässt, höchstens 3600. Auch, wie lange eine offene Seite einen entzogenen Grant oder Link überdauert |
ARTIFACT_ORIGIN |
(empty) | Von wo Inhalte ausgeliefert werden - die Seiten, ihr Bibliothekssatz und das Einbettungsdokument. Leer liefert sie von PUBLIC_BASE_URL aus, isoliert durch ihre sandbox-Policy. Setzen Sie ihn auf einen Host auf einer separaten registrierbaren Domain, auf diese API geroutet, um die Seite zusätzlich auf eine andere Site zu legen |
VISUAL_MAP_TILES |
(empty) | Der eine Kachelserver, auf dem ein Visual oder eine App eine Karte zeichnen darf: openstreetmap, usgs-topo oder eine https://-URL-Vorlage mit {z}, {x} und {y} je einmal. Das Deployment holt jede Kachel und liefert sie den Seiten über seinen eigenen Kachelpfad aus; keine Seite spricht mit dem Server. Leer zeichnet eine Karte als Umriss ihrer Stopps. Ein Wert, der sich nicht ausliefern lässt, wird beim Start abgewiesen |
VISUAL_MAP_TILES_ATTRIBUTION |
(empty) | Die Quellenangabe unter einer Karte auf einem eigenen Kachelserver, z. B. © OpenStreetMap contributors. Pflicht bei einer URL-Vorlage; ein Preset bringt seine eigene mit |
Kartenkacheln holt das Deployment, nicht der Browser des Lesers. Eine Seite
fragt /api/v1/artifact-content/tiles/{z}/{x}/{y}; die API prüft die Zahlen, holt
die Kachel vom Server, behält die letzten 2.048 im Speicher und antwortet, sodass
ein Schlüssel in der Vorlage den Server nie verlässt. Die Route braucht keine
Session - jede Seite lädt sie - und ist deshalb je Adresse begrenzt
(RATE_LIMIT_MAP_TILES_PER_MINUTE). Beachten Sie die Bedingungen des Anbieters:
die öffentlichen Kacheln von OpenStreetMap sind für leichte Nutzung, und ein viel
genutztes Deployment sollte einen eigenen Server betreiben oder einen Anbieter
bezahlen. Admin → System zeigt, ob der Server antwortet.
ARTIFACT_ORIGIN wird zweimal gelesen, und beide Stellen müssen ihn sehen.
Das Backend signiert Inhaltsadressen darauf, und das Frontend fügt ihn zum
frame-src der Konsole hinzu. Setzen Sie ihn in der Umgebung des Backends und in
der des Frontends; die Compose-Dateien reichen ihn an beide weiter. Ein Wert in
nur einer von beiden zeigt einen leeren Frame, weil der Browser sich weigert, die
Seite von einem Origin zu laden, den die Konsole nicht erlaubt hat.
Agent-Workspaces¶
Der state-Workspace braucht hier nichts. Er liegt in dieser Datenbank,
funktioniert auf jedem Deployment und ist das, was ein Agent standardmäßig
bekommt — die Einstellungen unten gelten also nur für einen containergestützten.
| Variable | Standard | Hinweise |
|---|---|---|
SANDBOX_STATE_MAX_BYTES |
32 MiB | Pro gespeichertem Workspace. Darüber hinaus wird ein Schreibvorgang mit einer Nachricht abgelehnt, die das Modell liest |
SANDBOX_INLINE_IMAGE_MAX_BYTES |
5 MiB | Darüber wird ein angehängtes Bild in den Workspace geschrieben und nicht zusätzlich inline gesendet |
Der Prozentsatz im Chat sind zwei verschiedene Obergrenzen, und er sagt,
welche. Ein gespeicherter Workspace füllt sich gegen
SANDBOX_STATE_MAX_BYTES oben — Bytes, und wenn sie ausgehen, wird ein
Schreibvorgang abgelehnt. Ein Container meldet residenten Speicher gegen die
Obergrenze, die sein Host für diese Runtime gesetzt hat, also 1g, sofern die
Allowlist nichts anderes sagt, und wenn der ausgeht, ist das ein OOM-Kill und
keine Ablehnung. Deshalb sagt der Streifen workspace 12% full für das eine und
sandbox memory 12% full für das andere; das eine als das andere zu melden hieße,
eine Grenze zu nennen, die nicht gilt.
Wo Sandboxes laufen, ist keine Einstellung. Es ist eine Zeile je Organisation
— Sandboxes in der App, sandbox_connections in der Datenbank — mit dem
Service-Token im Vault. Zwei Gründe, und keiner davon lässt sich in einer
Umgebungsvariablen ausdrücken: Ein Deployment kann mehr als einen Host halten, und
eine Adresse je Deployment gab jeder Organisation dieselbe; und das Token
autorisiert das Öffnen einer Session, die Befehle auf dem Host ausführt, der den
Docker-Socket hält, es gehört also dorthin, wo jede andere dauerhaft gespeicherte
Zugangsinformation liegt.
Ein Betreiber registriert eine Verbindung mit einem Namen, einer Adresse und einem Key aus dem Vault. Ein Agent nennt eine davon per id, genau wie er ein Model Profile nennt, oder nennt keine und nimmt die Standardverbindung der Organisation — der Wechsel auf einen anderen Host ist damit eine Änderung statt einer erneuten Veröffentlichung jedes Agents.
Das Service-Token ist so viel wert wie der Docker-Socket. Der Dienst hält
diesen Socket, der Socket ist eine unauthentifizierte API für root auf dem Host,
und das Token ist das, was darauf eine Session öffnet. Nie in einem Browser, nie
in einem Log, nie committet — deshalb zeigt der Bildschirm für Betreiber nur,
dass eine Zugangsinformation hinterlegt ist, und deshalb wird GET /policy über
diese API geleitet, statt vom Browser abgerufen zu werden. Das eigene Dashboard
des Dienstes (SANDBOXD_UI_ENABLED) ist aus demselben Grund in jeder
ausgelieferten Compose-Datei aus: Es verlangt von einem Menschen, diesen Wert in
einen Browser einzufügen.
SANDBOXD_TOKEN in backend/.env ist das eigene Token des Dienstes — das, was
der Daemon in der Compose-Datei annimmt.
make sandbox-token erzeugt es, und das Verbindungsformular legt denselben Wert
für Sie im Vault ab. Die API liest diese Einstellung für genau einen Zweck: um
ihn dem Vault anzubieten. Jemanden zu bitten, ein Secret aus einer Datei zu
kopieren, die sein eigener Stack ohnehin schon liest, ist Reibung ohne
Gegenwert.
Es wird nie benutzt, um einen Host zu erreichen — eine Verbindung aufzulösen entsiegelt den Vault-Eintrag, den diese Verbindung nennt, und das bleibt der einzige Weg. Ein Deployment, das es nicht setzt, verliert also eine Schaltfläche und sonst nichts und fügt das Token stattdessen von Hand ein.
Dasselbe Formular fragt, ob bereits ein Dienst antwortet, statt von einem
Betreiber zu verlangen zu wissen, dass ein Sandbox-Dienst aus make dev unter
http://sandboxd:8080 liegt. Diese Adresse ist keine Konfiguration, und das mit
Absicht — sie ist eine Zeile, weil ein Deployment mehrere Hosts halten kann —,
also prüft die API das unauthentifizierte /healthz unter der Adresse, die die
Compose-Datei dieses Projekts nutzt, und füllt vor, was geantwortet hat. Durch
das Fragen wird nichts entschieden: kein Dienst heißt ein leeres Feld, und eine
Verbindung, die bereits dorthin zeigt, wird benannt, damit niemand einen Host
zweimal registriert.
Die Adresse wird von dieser API abgerufen, also wird sie als solche
validiert. Eine Verbindung zu registrieren oder zu prüfen lässt den
API-Container ein authentifiziertes GET absetzen und reicht den JSON-Body
zurück, was ein Primitiv für Request Forgery ist, wenn die Adresse auf Treu und
Glauben genommen wird. base_url lehnt deshalb alles ab, was nicht http(s) mit
einem Host ist, und lehnt Link-Local-Adressen und die Hostnamen der
Instance-Metadata rundheraus ab — 169.254.169.254 und
metadata.google.internal sind nie ein Sandbox-Dienst.
Private Adressen bleiben erlaubt, und das müssen sie: http://sandboxd:8080
innerhalb von Compose und http://localhost:8080 für einen Entwickler, der die
API auf seinem eigenen Rechner laufen lässt, sind beide privat, eine Denylist für
private Bereiche würde also das Deployment ablehnen, das diese Seite beschreibt.
Der Validator verengt das Loch also, statt es zu schließen — ein Hostname, der auf
etwas Internes zeigt, tut das weiterhin. Die Grenze, die tatsächlich hält, ist
connections:manage plus Egress-Policy auf dem API-Container: Wer einen Host
registrieren darf, dem wird einer zugetraut, und ein Deployment in einem Netz mit
unauthentifizierten internen APIs sollte das im Netz sagen und nicht hier.
Welche Umgebungen ein Agent anfordern darf¶
Eine Runtime wird ausgeliefert — workbench (1,93 GB): Python 3.12, Node 24,
LibreOffice und die Bibliotheken, die ein Agent braucht, um die Dateien zu lesen,
zu schreiben, zu konvertieren und zu plotten, um die es in einer Unterhaltung
geht, liteparse mit OCR eingeschlossen. Sie ist in
backend/app/core/catalog/sandbox_runtimes.json definiert. Eine hinzuzufügen ist
eine Änderung dort plus make sandbox-runtimes, was SANDBOXD_RUNTIMES in alle
drei Compose-Dateien schreibt; diese Variable ist der einzige Kanal, über den der
Dienst Runtimes annimmt, und PUT /policy lehnt die Zusammensetzung der Liste
bewusst ab.
sandbox.md#which-environments-an-agent-may-ask-for hat das Format Feld für
Feld, die drei Fallen (der erste Eintrag ist die Voreinstellung, network_mode
wird nicht vererbt, ein Build wird beim Start durch prewarm bezahlt) und warum
die generierte Kopie in den Compose-Dateien nicht vom Katalog abweichen kann.
Die Einstellungen des Dienstes selbst¶
Jedes Feld der Konfiguration des Dienstes ist SANDBOXD_ plus sein Name, das
hier ist also eine Teilmenge und kein Vokabular. Dies sind die, die die
ausgelieferten Compose-Dateien setzen oder die darüber entscheiden, ob Dateien
überleben:
| Variable | Ausgeliefert | Worüber sie entscheidet |
|---|---|---|
SANDBOXD_WORKSPACE_ROOT |
a host path | Wo das Arbeitsverzeichnis jeder Session liegt, per Bind-Mount vom Host. Nicht gesetzt existieren Dateien nur innerhalb eines laufenden Containers — ein Abräumen im Leerlauf verwirft sie, und die nächste Anfrage öffnet einen leeren Workspace, ohne dass etwas in einem Log steht. Es ist auch das, was das Browsen möglich macht: einen Workspace zu lesen startet nie einen Container |
SANDBOXD_SANDBOX_UID |
10001 |
Der unprivilegierte Benutzer, als der eine Sandbox läuft, statt als root — ein Ausbruch aus einem Container beginnt bei dem, als der der Container läuft, und jede Datei, die ein Agent schreibt, gehört auf dem Host dieser uid. Muss die uid des Dienstes selbst sein: Eine Session zu öffnen chownt den Workspace auf diesen Benutzer, und das kann ein unprivilegierter Dienst nur für sich selbst tun. Gilt für eine Runtime, die das Deployment baut, denn ein fertiges Image hat kein solches Konto, und ein Agent darin könnte nichts installieren |
SANDBOXD_CONTAINER_TTL |
86400s | Wie lange ein gestoppter persistierter Container aufbewahrt wird. Holt zurück, was eine Session installiert hat — den Build, die Wheels, node_modules — und lässt den Workspace unangetastet, weil die Dateien die Arbeit sind. Nicht gesetzt werden sie für immer aufbewahrt |
SANDBOXD_PERSIST_CONTAINERS |
true |
Der Container einer geschlossenen Session wird aufbewahrt statt entfernt, damit die nächste Session auf diesem Workspace ohne Build startet. Kostet einen gestoppten Container je Workspace; SANDBOXD_CONTAINER_TTL begrenzt das |
SANDBOXD_MAX_SESSIONS_PER_TENANT |
5 |
Eine Organisation kann den Pool nicht allein belegen. SANDBOXD_MAX_SESSIONS (20) ist der Pool |
SANDBOXD_NETWORK_MODE |
none |
Das Standardnetz einer Sandbox. none ist gar kein Netz; eine Runtime darf für sich bridge nennen |
SANDBOXD_UI_ENABLED |
0 |
Das eigene Dashboard des Dienstes. Aus, weil es von einem Menschen verlangt, ein root-äquivalentes Token in einen Browser einzufügen |
SANDBOXD_IDLE_TIMEOUT |
1800s | Wie lange eine untätige Session lebt, bevor sie geschlossen und abgeräumt wird |
SANDBOXD_MEM_LIMIT |
1g |
Die Standard-Speicherobergrenze und damit die Zahl, von der der Prozentsatz sandbox memory im Chat ein Anteil ist |
Den Dienst auf einem anderen Host betreiben¶
Nichts an einer Verbindung setzt eine lokale Adresse voraus — sie ist eine Zeile mit einer URL und einer Vault-Zugangsinformation, und das Formular prüft, was immer es bekommt. Ein Host anderswo braucht drei Dinge und keinen Code:
- Den Docker-Socket, weil der Dienst Container startet. Das ist root auf dieser Maschine, weshalb das Token unten so viel wert ist, wie es wert ist.
SANDBOXD_WORKSPACE_ROOTauf echter Platte, auf beiden Seiten unter demselben Pfad eingehängt. Der Dienst legt das Verzeichnis an und bittet dann den Daemon, es per Bind-Mount einzuhängen, und der Daemon löst den Pfad auf dem Host auf — ein Named Volume oder ein Pfad, den es nur im Container des Dienstes gibt, wird mitmounts deniedabgelehnt.- TLS und ein Token, das niemand teilt. Innerhalb von Compose ist die Adresse
http://sandboxd:8080in einem privaten Netz; über das Internet hinweg ist es ein Dienst, der für jeden Befehle ausführt, der das Token hält, er gehört also hinter HTTPS mit einem eigenen Wert.
Registrieren Sie ihn danach in Sandboxes wie jeden anderen und richten Sie einen Agent per Namen darauf. Der Compose-Dienst ist ein Deployment desselben Images.
Wenn eine Session offen ist¶
Der Tab Running listet die Sessions, die der Dienst hält, alle zehn Sekunden neu abgerufen, und eine Session ist ein Workspace auf einem Host. Drei Zustände, und nur die ersten beiden treten auf:
- running — der Container existiert und ist resident. Wird durch den ersten Tool-Aufruf eines Agents in einer Unterhaltung geöffnet, nicht wenn die Unterhaltung beginnt.
- hibernated — die Zeile existiert und der Container nicht. Eine Session, die
länger als
SANDBOXD_EVICT_IDLE_AFTERuntätig ist, wird schlafen gelegt, um einen Platz freizugeben, und ihre nächste Anfrage weckt sie. Das brauchtWORKSPACE_ROOT, sonst würde das Aufwecken einen leeren Workspace öffnen, und der Dienst lehnt die Kombination ab, statt das zu tun. - gone — nach
SANDBOXD_IDLE_TIMEOUTwird die Session geschlossen und abgeräumt. MitPERSIST_CONTAINERSüberlebt der Container das, die nächste Session auf demselben Workspace startet also ohne Build.
Ein leerer Running-Tab heißt also, dass kein Agent kürzlich eine Shell benutzt hat, und nicht, dass nichts konfiguriert ist — und ein Workspace mit Dateien darin und ohne Session ist der normale Ruhezustand.
Der Dienst läuft hinter dem Compose-Profil sandbox, das in der lokalen
Entwicklung standardmäßig an ist und überall sonst aus, bis ein Betreiber es
einschaltet — den Docker-Socket auf einem geteilten Host einzuhängen ist ein
bewusster Akt. COMPOSE_DEV_PROFILES im Makefile ist die eine Stelle, an der
sich das ändern lässt. uv run agenticos cmd doctor prüft jede registrierte
Verbindung: ob sie antwortet, ob sie ihre Zugangsinformation annimmt und ob sie
überhaupt irgendeine Runtime zulässt. Keine registrierte Verbindung ist eine
Warnung, kein Fehler — der state-Workspace braucht keine.
Browsen, was die Agents behalten haben. Workspaces ist ein eigener Bildschirm — nicht Teil von Sandboxes, wo es um Hosts geht.
Jede Zeile nennt den Agent, die Unterhaltung, zu der die Dateien gehören (oder wie viele Chats sie erreichen, bei einem Workspace, den keine einzelne Unterhaltung besitzt), wer sie sehen kann, wie groß er ist und wann er zuletzt genutzt wurde.
Open führt auf die eigene Seite dieses Workspace, in der Form, die der Skills-Editor nutzt: der Baum links — Ordner werden einzeln durchlaufen, mit einem Suchfeld über dem ganzen Baum statt über dem Ordner auf dem Bildschirm — und die Datei selbst daneben gerendert. Drei Dateien zu lesen sind also drei Klicks, und die Liste schließt sich nie.
Der Download sitzt auf der Zeile und nicht neben dem Reader, weil eine Datei auszuwählen sie liest und ein großes Archiv eines ist, von dem jemand eine Kopie will, ohne dafür zu bezahlen.
Eine zweite Ansicht auf der Liste flacht jede Datei, die die lesende Person sehen kann, in ein Raster ab — die Frage „wer hält gerade eine Kopie dieser CSV“, die die Seite je Workspace nicht beantworten kann.
Ein Klick auf eine Datei öffnet sie in einem Viewer, und es ist derselbe Viewer
wie im Chat-Panel. Ein Bild ist ein Bild, ein PDF ist die PDF-Ansicht des
Browsers, Audio und Video spielen im eigenen Player des Browsers, Markdown bietet Preview und Source — beides ist die Datei, und ein
#, aus dem still große Schrift wurde, ist die Art, wie jemand nicht bemerkt,
dass sein Agent Markdown in etwas schreibt, das nichts als Markdown liest —, und
alles andere ist sein Text. Der Download ist immer da, auch für das, was sich gar
nicht anzeigen lässt. Eine Komponente, denn „diese Datei öffnen“, das auf zwei
Bildschirmen zwei verschiedene Dinge heißt, ist die Art, wie dem zweiten ein Fall
fehlt.
Die Bytes kommen von GET /sandbox-workspaces/{id}/raw?path=… oder von
GET /conversations/{id}/workspace/raw?path=… für das Panel neben einem Chat.
Zwei Routen statt einer, weil sie unterschiedliche Aufrufende autorisieren — die Route der Unterhaltung wird erreicht, indem die Unterhaltung geladen wird, sodass jemand, mit dem ein Chat geteilt wurde, den Zugriff behält — und weil ein Modul entscheidet, was angezeigt werden darf, damit die Antwort nicht je Oberfläche abweichen kann.
Fast alles wird als Anhang ausgeliefert. Rasterbilder, PDFs, Audio und Video (MP3, WAV, Ogg, Opus, M4A, MP4, WebM) werden zur Anzeige ausgeliefert: ein Raster, weil es nichts ausführen kann, ein PDF, weil der Browser es in seinem eigenen Viewer rendert, der nie das DOM der Seite bekommt, und Audio und Video, weil der Browser sie seiner Medienwiedergabe übergibt, die sie abspielt und nichts in der Seite ausführt.
SVG und HTML sind herunterladbar und nie anzeigbar
Ein SVG, das von dieser Origin inline ausgeliefert wird, ist gespeichertes Cross-Site-Scripting, geschrieben von dem, was der Agent zu speichern beschlossen hat, und „der Agent hat es geschrieben“ ist keine Vertrauensgrenze.
Alles andere wird als application/octet-stream mit
X-Content-Type-Options: nosniff typisiert, damit ein Browser nicht doch noch
entscheiden kann, ein solcher Body sei HTML. Der Dateiname reist nur als
filename*, weil ein Workspace-Pfad beliebiges UTF-8 halten kann und die nackte
Form keine Möglichkeit hat, das zu sagen.
Nur ein gespeicherter Workspace kann beliebige Bytes ausliefern. Ein containergestützter wird über das Archiv des Workspace gelesen, dessen einziger Leser textuell ist, eine Textdatei wird also durch Kodieren ausgeliefert und alles andere abgelehnt, statt still verstümmelt zu werden — der Browser bietet den Download neben der Ablehnung an, damit die Antwort nie eine Sackgasse ist.
Dateien werden nur gelesen, wenn ein Workspace geöffnet wird oder wenn die flache Ansicht eingeschaltet wird: Ein Deployment kann einen je warmer Unterhaltung halten, jeden davon zum Rendern der Tabelle zu lesen wäre also eine Anfrage je Zeile für eine Seite, an die noch niemand eine Frage gestellt hat. Die flache Ansicht ist aus demselben Grund begrenzt und sagt das — wie viele Workspaces sie gelesen hat, wie viele nicht, und ob es weitere gibt. Eine kürzere Liste ist sonst nicht von weniger Dateien zu unterscheiden.
Wer welchen Workspace sieht, wird je lesender Person in der Abfrage
entschieden. Eine aufrufende Person mit connections:manage sieht die der
Organisation — die ehrliche Latte für eine Liste, die Chats überquert, die ihr
nicht gehören. Alle anderen sehen die Workspaces, an denen sie beteiligt sind:
ihre eigenen user-skopierten Dateien, die Workspaces ihrer eigenen
Unterhaltungen und den geteilten Workspace eines Agents, mit dem sie gesprochen
haben. Bewusst „gesprochen haben“ statt „öffnen könnten“: agent-Scope teilt
einen Workspace über die Nutzer eines Agents hinweg, und das Chat-Panel zeigt
diese Dateien ohnehin jedem in einer Unterhaltung mit ihm, den Agent öffnen zu
können ist also ein weiterer Anspruch, als diese Liste erhebt.
channel-Scope ist nur für einen Betreiber sichtbar, und das ist richtig statt
ein Versehen — er hängt an einem Slack- oder Telegram-Chat, die Menschen, die ihn
teilen, sind also über diese Plattform identifiziert und nicht über eine Zeile in
users.
Ein per id geladener Workspace wendet dieselben drei Prädikate an und antwortet not found statt forbidden, wenn sie scheitern: Eine id darf nicht nutzbar sein, um herauszufinden, welche Workspaces in der Unterhaltung einer Kollegin existieren. Nichts hier überquert eine Organisation — ein App-Admin, der die Dateien eines anderen Tenants durchsieht, wäre der eine Lesevorgang, den diese Plattform ablehnt, also wechselt er die Organisation wie alle anderen.
Ein containergestützter Workspace wird vom Host-Volume gelesen, und dafür
braucht es eines. Der Sandbox-Dienst liefert diese Dateien aus
SANDBOXD_WORKSPACE_ROOT, und das ist es, was eine Unterhaltung vom letzten Monat
ihre Dateien listen lässt, nachdem ihre Session abgeräumt wurde — es wird kein
Container gestartet, um zu antworten. Ein Dienst, der ohne eines konfiguriert
ist, behält nichts auf der Platte, seine Dateien existieren also nur, solange eine
Sandbox läuft, und lassen sich nicht lesen, ohne eine zu starten: Das Files-Panel
könnte dann nur das sagen, für eine Datei, die der Agent nachweislich gerade
geschrieben hatte.
Jede Compose-Datei setzt deshalb eines, übersteuerbar mit
SANDBOX_WORKSPACE_ROOT — eine Umgebungsvariable dort, wo Compose sie
interpoliert, also im Projektwurzelverzeichnis statt in backend/.env, außer bei
den Targets dev und prod, die diese Datei ausdrücklich übergeben.
Ein Host-Pfad, auf beiden Seiten an derselben Stelle per Bind-Mount eingehängt,
weil der Dienst das Verzeichnis anlegt und dann den Daemon bittet, es
einzuhängen — und der Daemon löst den Pfad auf dem Host auf. Ein Named Volume
oder irgendein Pfad, den es nur im Container des Dienstes gibt, wird mit
mounts denied abgelehnt.
| Standard | ||
|---|---|---|
| Lokale Entwicklung | /tmp/agenticos-sandbox-workspaces |
Docker Desktop teilt es und jeder darf hineinschreiben, ein Laptop braucht also keine Einrichtung |
| Die Server-Dateien | /var/lib/agenticos/sandbox-workspaces |
Muss existieren und für uid 10001 beschreibbar sein — sudo mkdir -p <path> && sudo chown 10001:10001 <path>, einmalig. Nicht install -d -o 10001: install löst den Eigentümer über die passwd-Datenbank auf und lehnt eine uid ab, der kein Konto gehört. Es gehört auf Speicher, den jemand sichert |
Ein Neustart fegt /tmp leer, und das ist der eine Grund, ein echtes Deployment
nicht dorthin zu richten.
Das wird gemeldet und nicht geworfen. Jede Liste trägt unreadable_reason, und
ein Client zeigt es als Erklärung statt als Fehler — denn keine der beiden
Ursachen ist ein Defekt: Ein Dienst, der nichts auf der Platte behält, ist eine
Konfiguration mit einer einzeiligen Lösung, die die Nachricht nennt, und ein Host,
der unten ist, ist später wieder oben.
Es zu werfen machte daraus eine 500, die ein Browser nur als „etwas ist schiefgelaufen“ rendern konnte, neben einer leeren Liste, die sich als „es gibt keine Dateien“ liest. Zwei falsche Antworten auf einmal.
Eine Datei von einem solchen Host zu lesen wird mit demselben Satz abgelehnt, statt als „keine solche Datei“ gemeldet zu werden, was sagen würde, die Datei fehle, obwohl sie da ist.
Was läuft, wird ebenfalls vom Dienst gelesen.
Der Bildschirm Sandboxes hält das auf einem eigenen Tab, getrennt von der Tabelle der Verbindungen, und listet die offenen Sandboxes dieser Organisation auf dem Host, den er nennt — der Standardverbindung, bis der Betreiber eine andere wählt.
Jede Zeile trägt die Runtime, was sich diese Sandbox teilt, ihre Leerlaufzeit und ihren Speicher gegen ihre eigene Obergrenze, wenn danach gefragt wird. Sortierbar nach Leerlaufzeit und Speicher. Daneben steht das Aktivitätsprotokoll je Sandbox: welche Pfade gelesen wurden, welche Befehle liefen und wie jeder davon ausging.
Weder Dateiinhalte noch Befehlsausgaben zeichnet der Dienst auf, und genau das hält einen Audit-Trail davon ab, ein Weg zu werden, die Arbeit eines anderen Agents zu lesen.
Das Dashboard beantwortet dieselben drei Fragen in einem eigenen Abschnitt, für
eine aufrufende Person mit connections:manage. Der Speicher liegt dort aus
demselben Grund hinter einem Schalter wie auf dem Bildschirm: Der Dienst
beprobt jede Sandbox dafür einzeln.
Alle drei Obergrenzen teilen jetzt sauber.
Die Session-Liste ist auf die Organisation der aufrufenden Person gefiltert, aber
sie reicht SANDBOXD_MAX_SESSIONS und SANDBOXD_MAX_OPEN_SESSIONS unverändert
vom Dienst durch — diese beiden zählen also jeden Tenant auf dem Host, während
die Zeilen einen zählen. len(sessions) teilt nur gegen
SANDBOXD_MAX_SESSIONS_PER_TENANT.
Die Antwort trägt deshalb zwei hostweite Zähler für das andere Paar, genommen aus der ungefilterten Liste, bevor der Filter sie verengt:
host_session_count— die residenten Sandboxes, die der Dienst alsstate == "running"markiert, gegenlimit;host_open_count— jede Session, die existiert, resident oder schlafend, gegenopen_limit.
Jetzt kann die Kapazitätskarte sagen, warum eine Session abgelehnt wurde, während diese Organisation unter ihrer eigenen Obergrenze liegt: Der Host selbst ist voll mit der Arbeit von jemand anderem.
Dass die beiden hostweit sind, ist eine bewusste, eng gefasste Offenlegung — zwei
aggregierte ganze Zahlen, die nichts benennen, weit entfernt von den
Session-Zeilen, die der Filter zurückhält — und die Liste ist auf
connections:view gegattert, die Befugnis, einen Host zu beobachten, und nicht
die irgendeines Mitglieds.
Bei einer Daytona-Verbindung sind sie None, denn sie erzwingt keine unserer
Obergrenzen, die sich teilen ließen.
Diese Liste wird gefiltert, nicht durchgereicht. Ein sandboxd antwortet für
jede Organisation, die an seiner Adresse eine Verbindung registriert hat, seine
Antwort durchzureichen zeigte einem Tenant also die Container eines anderen.
Sessions werden über das tenant-Label abgeglichen, das diese Plattform setzt,
wenn sie eine öffnet, und aus agent_workspaces benannt statt durch Dekodieren
der Session-id — die id kodiert den Scope-Key, und ihn zurückzuparsen machte aus
diesem Format ein Schema.
Was der Dienst zulässt, wird vom Dienst gelesen. Die Allowlist der Runtimes
und die Obergrenze hinter jedem Alias (SANDBOXD_RUNTIMES, SANDBOXD_MEM_LIMIT,
SANDBOXD_NETWORK_MODE, SANDBOXD_MAX_SESSIONS_PER_TENANT und der Rest) sind
seine eigene Boot-Konfiguration, und es gibt bewusst keinen Endpunkt, sie zu
schreiben: Ein Browser, der den Prozess umkonfigurieren könnte, der den
Docker-Socket hält, besäße den Host. Der Bildschirm Sandboxes und die
Runtimes-Karte des Dashboards lesen sie beide, damit sichtbar ist, was gilt, und
der Builder bietet einem Agent nur die Aliase an, die der Dienst tatsächlich
annimmt.
Keine der beiden Ansichten fragt eine Daytona-Verbindung danach. Sie veröffentlicht keine eigene Allowlist und hält keine unserer Sessions zum Aufzählen — was sie erlaubt, ist eine Einstellung auf diesem Konto, und was dort läuft, ist in ihrem eigenen Dashboard sichtbar.
Messaging-Kanäle¶
| Variable | Standard | Beschreibung |
|---|---|---|
Bot-Zugangsdaten werden nicht hier konfiguriert: Jeder Bot wird in der App
registriert, sein Token im Vault versiegelt, und ein Slack-Bot trägt zusätzlich
das Signing Secret seiner eigenen App und ein xapp--Token
(SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET und SLACK_APP_TOKEN wurden entfernt
— jeder Bot ist jetzt seine eigene Slack-App). Telegram-Webhook-URLs werden aus
PUBLIC_BASE_URL gebaut (TELEGRAM_WEBHOOK_BASE_URL wurde entfernt), Model
Profiles dürfen ohne jedes Flag auf lokale Endpunkte wie Ollama zeigen
(ALLOW_INTERNAL_MODEL_ENDPOINTS wurde entfernt), und die Sandbox-Grenzen von
run_python sind Capability-Konfiguration je Agent
(CODE_EXECUTION_TIMEOUT_SECS / CODE_EXECUTION_MAX_MEMORY_MB wurden entfernt).
Sprachanrufe¶
Sprachanrufe werden in jedem Agent eingeschaltet und durch seinen längsten Anruf und die Budgets begrenzt. Zwei Obergrenzen gehören dem Deployment:
| Variable | Standard | Beschreibung |
|---|---|---|
VOICE_MAX_CALLS_PER_USER |
1 |
Anrufe, die eine Person gleichzeitig führen darf. Der Anruf eines zweiten Tabs ist fast immer ein Versehen, und jeder wird abgerechnet; ein zweiter Start wird als busy abgelehnt |
VOICE_MAX_CALLS_PER_EMBED |
3 |
Anrufe, die ein Widget oder eine Hosted Page gleichzeitig führen darf, von allen Besuchenden zusammen; der nächste wird als line_busy abgelehnt. Besuchende haben kein Konto, auf das gezählt werden könnte, also ist das, was einen geleakten Link davon abhält, das Budget der Organisation parallel zu verbrauchen |
Beide zählen Anrufe, deren Heartbeat frisch ist, sodass ein Anruf, dessen
Prozess gestorben ist, innerhalb von 45 Sekunden nicht mehr zählt. Das Starten
von Anrufen auf einem Embed ist außerdem rate-limitiert
(RATE_LIMIT_EMBED_VOICE_*, unten). Das eigene
rate_limit_per_minute eines Embeds, im Builder gesetzt, zählt die
Nachrichten einer besuchenden Person, keine Anrufstarts, und begrenzt Anrufe
deshalb nicht.
Der Rest ist im Code festgelegt statt konfiguriert: Ein Anruf ist nie länger als
50 Minuten, Heartbeats laufen alle 15 Sekunden, und der Voice-Sweep läuft jede
Minute, als Zeitplan reap-voice-calls des Workers.
CORS¶
| Variable | Standard | Beschreibung |
|---|---|---|
CORS_ORIGINS |
["http://localhost:3000","http://localhost:8080"] |
Erlaubte Origins (JSON-Array) |
CORS_ALLOW_CREDENTIALS |
true |
Credentials (Cookies) erlauben |
CORS_ALLOW_METHODS |
["*"] |
Erlaubte HTTP-Methoden |
CORS_ALLOW_HEADERS |
["*"] |
Erlaubte HTTP-Header |
Prüfung für die Produktion: CORS_ORIGINS darf bei ENVIRONMENT=production kein
"*" enthalten.
Rate Limiting¶
Wird auf die Oberflächen angewandt, die eine fremde Person erreichen kann, und nur auf diese: die öffentliche Run-API, das Skript des Widgets, dessen Config, den Socket-Handshake beider Oberflächen, Config und Logo einer Hosted Page sowie den Upload, das Diktat und den Sprachanruf einer besuchenden Person. Die Routen der Konsole selbst liegen hinter einer Session und werden nicht gemessen — ob die ganze API eine Obergrenze tragen sollte, ist eine eigene Entscheidung und nicht diese.
| Variable | Standard | Beschreibung |
|---|---|---|
RATE_LIMIT_RUN_PER_MINUTE |
30 |
POST /api/v1/agents/{id}/run, je aufrufender Seite |
RATE_LIMIT_AUTH_PER_MINUTE |
10 |
Jede Route in auth.py — Login, Registrierung, Refresh, die Anfrage- und Verifikationsrouten für Reset und Magic Link. Gezählt je IP und, wo der Body eine trägt, je übermittelter Adresse. Siehe unten |
RATE_LIMIT_EMBED_PER_MINUTE |
20 |
Je Adresse, und zwei getrennte Zähler dieser Größe: einer für widget.js, einer für die Zulassung — das /config des Widgets plus den Socket-Handshake beider Oberflächen. Siehe unten |
RATE_LIMIT_HOSTED_PAGE_PER_MINUTE |
240 |
Die Config einer Hosted Page, je Seite — und ihr Logo, auf einem eigenen Zähler. Siehe unten |
RATE_LIMIT_MAP_TILES_PER_MINUTE |
600 |
Kartenkacheln, die eine Adresse über den Kachelpfad des Deployments holen darf, je Adresse. Eine Karte sind einige Dutzend Kacheln, Verschieben bringt mehr; die Grenze verhindert, dass das Deployment jedermanns kostenloser Proxy zum Kachelserver wird |
RATE_LIMIT_EMBED_UPLOAD_PER_MINUTE |
5 |
Dateien, die eine besuchende Person auf einer Hosted Page ablegen darf. Gezählt je Adresse und je Visitor Key, und beide müssen es zulassen — der Key wird vom Browser erzeugt, nur ihn zu zählen begrenzt also nichts |
RATE_LIMIT_ML_PER_MINUTE |
30 |
Die ML-Dienste, pro Aufrufer. Diese Endpunkte erledigen ihre Arbeit synchron, ein unbegrenzter Aufrufer belegt also den Parsing-Pool statt eines Budgets |
RATE_LIMIT_DICTATION_PER_MINUTE |
10 |
Diktieren im Chat, pro Person, auf einem eigenen Zähler, damit Diktieren und eine Integration, die die ML-Dienste aufruft, einander nicht das Kontingent verbrauchen. Jedes Diktat ist ein Aufruf beim kostenpflichtigen Provider der Organisation |
RATE_LIMIT_VOICE_PREVIEW_PER_MINUTE |
6 |
Eine Stimme anhören im Builder, pro Person. Jede Vorschau außerhalb des Caches ist eine kurze kostenpflichtige Sitzung beim Realtime-Provider des Agents, außerhalb aller Modellbudgets; eine einmal gehörte Stimme bleibt eine Woche im Cache |
RATE_LIMIT_EMBED_DICTATION_PER_MINUTE |
6 |
Diktat in einem Widget oder auf einer Hosted Page, pro Adresse und bei einem jwt-Widget pro besuchender Person. Jedes Diktat ist ein Aufruf beim kostenpflichtigen Provider der Organisation, außerhalb jedes Modell-Budgets |
RATE_LIMIT_EMBED_DICTATION_PER_LINK_PER_MINUTE |
10 |
Dasselbe, pro Seite oder Widget, von allen Besuchenden zusammen - die Obergrenze dessen, was ein geleakter Link kostet: so viele Aufnahmen pro Minute, jede bis zum längsten Diktat der Organisation. Erst verbraucht, wenn ein Besucher zugelassen ist |
RATE_LIMIT_EMBED_VOICE_PER_MINUTE |
3 |
Sprachanrufe, die in einem Widget oder auf einer Hosted Page gestartet werden, pro Adresse und bei einem jwt-Widget pro Token-Subject der besuchenden Person. Die Adresse wird gezählt, bevor der Schlüssel nachgeschlagen wird; jeder Anruf ist eine kostenpflichtige Realtime-Session |
RATE_LIMIT_EMBED_VOICE_PER_LINK_PER_MINUTE |
6 |
Dasselbe, pro Seite oder Widget, von allen Besuchenden zusammen, erst verbraucht, wenn ein Besucher zugelassen ist. Zusammen mit VOICE_MAX_CALLS_PER_EMBED oben die Obergrenze dessen, was ein geleakter Link kostet |
RATE_LIMIT_TRUST_FORWARDED_FOR |
false |
Ob X-Forwarded-For die aufrufende Seite benennt |
Was eine abgelehnte aufrufende Seite bekommt, ist der eigene Fehlerumschlag
dieser API mit code: "RATE_LIMIT_EXCEEDED", dem Intervall in
error.details.retry_after_seconds und demselben Intervall im Header
Retry-After — auf den ein Fetch-Wrapper oder ein CDN tatsächlich zurücknimmt.
Der Socket-Handshake ist die Ausnahme, weil ein WebSocket keinen Status hat, mit
dem er antworten könnte: Er schließt mit 4029 (siehe
Kanäle).
Zwei Zähler, nicht einer, und der Grund ist Arithmetik.
Eine Seite mit einem Widget darauf zu laden kostet drei Anfragen an diese API: das
Skript, die Config und den Socket. Zusammen gezählt kauften 20 für einen kalten
Browser etwa sieben Seitenaufrufe statt zwanzig Zulassungen — und eine Grenze, die
um den Faktor drei falsch ist, ist schlimmer als keine Grenze, weil sie sich als
die Zahl liest, die Sie gesetzt haben.
widget.js hat deshalb einen eigenen Topf. Es ist cachebar, und eine Ablehnung
dort macht das Widget ganz kaputt, statt eine Nachricht zu verzögern.
Die Config und der Handshake bleiben zusammen, weil sie zusammen eine Zulassung sind: Ein Browser, der eine Config gelesen und keinen Socket geöffnet hat, ist nicht hineingekommen.
Die Zählstände liegen im Redis des Deployments, sie halten also über Worker hinweg — die Produktion fährt vier, und ein Zählstand je Prozess ließe das Vierfache dessen durch, was er sagt. Ist Redis nicht erreichbar, wird die Grenze nicht angewandt und eine Warnung protokolliert: Einer besuchenden Person ihre Antwort zu verweigern, weil ein Cache gezuckt hat, ist das schlimmere der beiden Versagen.
Was eine besuchende Person, einmal zugelassen, sagen darf, ist eine andere Zahl,
je Widget im Builder gesetzt (rate_limit_per_minute) und je besuchender Person
gezählt. Diese beiden hier sind die Obergrenze fürs Hineinkommen.
RATE_LIMIT_HOSTED_PAGE_PER_MINUTE, und warum es nicht je Adresse zählt¶
Die Config einer Hosted Page wird serverseitig vom Frontend geholt, damit die
Seite im ersten Frame gebrandet erscheint. Das heißt, die Adresse auf der Anfrage
ist die des Frontend-Containers und nicht die der besuchenden Person — sie zu
zählen legte also jeden Aufruf einer Hosted Page im Deployment in einen einzigen
Topf, und die besuchende Person, die ihn auslöste, bekam eine 404 ohne jeden
Hinweis, warum. RATE_LIMIT_TRUST_FORWARDED_FOR hilft nicht: Ein serverseitiges
fetch schickt keinen solchen Header, dem jemand vertrauen könnte.
Dieses eine wird deshalb je Public Key gezählt. Es begrenzt eine einzelne Seite,
statt eine besuchende Person zu rationieren, weshalb die Voreinstellung weit ist
— es ist nicht das, was Ausgaben begrenzt. Ausgaben beginnen an dem Socket,
den die Seite als Nächstes öffnet, den der Browser aufbaut und der je Adresse
unter RATE_LIMIT_EMBED_PER_MINUTE gezählt wird. Und einen Key zu raten ist keine
Strategie gegen 192 Bit secrets.token_urlsafe.
RATE_LIMIT_AUTH_PER_MINUTE, und warum die Auth-Oberfläche eine eigene hat¶
Jede Route in auth.py trägt diese Grenze, gezählt je IP und — wo der Body
eine Adresse trägt (Login, Registrierung, die Anfragen für Reset und Magic Link) —
auch je übermittelter Adresse, beide gegen dieselbe Zuteilung. Die beiden
halten unterschiedliche Angriffe auf: Die IP begrenzt eine Flut aus einer Quelle,
die Adresse begrenzt einen Brute-Force-Angriff auf ein Konto.
Sie ist von der Run-Zuteilung getrennt und niedriger als diese, weil sie die
Kosten eines einzelnen Versuchs abwehrt. verify_password ist bcrypt, ~170 ms
ohne Unterbrechungspunkt, eine ungemessene Flut auf /login für jede Adresse, die
ein Konto hat, sättigt also den Event Loop eines Workers ganz ohne Zugangsdaten.
Zwei weitere Dinge schließen den Rest dieser Oberfläche und brauchen keine Konfiguration:
- bcrypt läuft in einem Thread, blockiert den Loop also nie;
- eine Adresse ohne Konto wird gegen einen Dummy-Hash geprüft statt übersprungen, eine bekannte und eine unbekannte Adresse brauchen für die Ablehnung also gleich lang und das Timing sagt nicht mehr, welche Adressen es gibt.
RATE_LIMIT_TRUST_FORWARDED_FOR, und warum es aus ist¶
Grenzen je Adresse zählen request.client.host. Hinter einem Proxy oder einem
CDN ist das die Adresse des Proxys und nicht die der besuchenden Person — jede
besuchende Person teilt sich einen Topf, eine belebte Seite hinter Cloudflare
erschöpft die zwanzig Zulassungen des Widgets pro Minute also für alle auf einmal.
Das hier einzuschalten liest stattdessen den rechtesten X-Forwarded-For-Hop
— die Adresse, die der vertraute Proxy selbst angehängt hat.
Es ist standardmäßig aus, weil den Header setzt, wer auch immer aufruft. Bedingungslos vertraut, wird aus einer Grenze je Adresse eine Grenze je Header, die jeder umgeht, indem er eine Zeichenkette variiert.
Der rechteste Hop wird aus demselben Grund gelesen und nicht der linkeste:
X-Forwarded-For ist eine Liste, die der Client beginnt und an die jeder Proxy
anhängt, der Kopf ist also das, was der Client getippt hat, und nur das Ende ist
das, was ein Proxy geschrieben hat, den Sie kontrollieren.
Die Auth-Oberfläche braucht das ebenfalls, und das Frontend macht es jetzt
möglich. Auth-Anfragen erreichen die API serverseitig über die eigenen Routen
/api/auth/* des Frontends, ohne Hilfe ist die Adresse darauf also die des
Frontend-Containers, und die Je-IP-Hälfte von RATE_LIMIT_AUTH_PER_MINUTE legt
das ganze Deployment in einen Topf — etwa elf Logins sperren dann alle für eine
Minute aus, und ein erschöpfter Refresh-Topf meldet Sessions ab. Anders als beim
Config-Abruf einer Hosted Page leiten diese Routen das X-Forwarded-For der
aufrufenden Seite weiter
(#1047), mit dieser
Einstellung an schlüsselt die Grenze also auf den echten Client. Schalten Sie sie
für die Auth-Grenze unter derselben Regel ein wie für alles andere — ein Proxy,
den Sie kontrollieren, davor, der den Client als rechtesten Hop anhängt —, und
genau diese Deployment-Entscheidung ist diese Einstellung; aus gelassen bleibt die
Grenze sicher, aber geteilt.
Schalten Sie es nur ein, wenn ein einziger Proxy, den Sie kontrollieren, das Einzige ist, was die API erreichen kann
Ist der Port des Containers ebenfalls veröffentlicht, kann eine aufrufende Seite den Header selbst setzen und die Grenze bedeutet nichts mehr.
Der Port des Frontends zählt hier als der der API. Seine Routen
/api/auth/* leiten jedes X-Forwarded-For weiter, das sie bekommen haben,
wer also am Proxy vorbei Port 3000 erreicht, wählt genauso sicher die Adresse,
gegen die seine Login-Versuche gezählt werden, wie jemand, der Port 8000
erreicht — und jeder angenommene Versuch gegen eine Adresse, die niemand hält,
kostet trotzdem ein bcrypt. docker-compose-prod.yml und
docker-compose-prod.frontend.yml veröffentlichen deshalb standardmäßig auf
127.0.0.1, wo der Reverse Proxy des Hosts sie erreicht und sonst nichts.
BIND_HOST=0.0.0.0 öffnet sie wieder, für einen Proxy, der tatsächlich
anderswo läuft — mit dem Netz dieses Proxys als dem, was das Versprechen hält.
Mit zwei Proxys davor falten Sie den Header an Ihrer Kante auf einen Hop zusammen — nur der letzte Hop ist vertrauenswürdig.
Ein Worker, dessen Event Loop sich nicht mehr dreht¶
| Variable | Standard | Beschreibung |
|---|---|---|
EVENT_LOOP_WEDGED_AFTER |
15 |
Sekunden, die der Event Loop stillstehen darf, bevor der Worker getötet und ersetzt wird. 0 oder darunter schaltet die Prüfung ab |
UVICORN_WORKER_HEALTHCHECK_TIMEOUT |
60 |
Sekunden, die uvicorns Supervisor auf die Antwort eines Workers auf seinen Ping wartet, bevor er ihn ersetzt — überall, wo --workers größer als 1 ist. Gelesen von den Compose-Dateien, nicht von der Anwendung |
Ein Worker, der lebt, aber nicht antwortet — verklemmt auf einem Lock, drehend
in einem synchronen Aufruf, blockiert auf einem Socket, der nie antwortet —, hat
keinen Exit-Code, jeder Wiederherstellungspfad in jedem Stack las ihn also als
gesund, während Anfragen in Timeouts liefen. Der Container geht auf unhealthy,
und ein Status ist kein Mechanismus.
Also beurteilt der Worker seinen eigenen Event Loop. Ein Timer-Callback stempelt
den Loop einmal pro Sekunde; ein Thread liest den Stempel, und wenn sich der Loop
über zwei aufeinanderfolgende Prüfungen hinweg EVENT_LOOP_WEDGED_AFTER lang
nicht gedreht hat, beendet er den Prozess — SIGKILL, oder os._exit(137), wo
der Worker PID 1 ist, weil der Kernel dem init eines Namespace kein Signal
zustellt, für das init keinen Handler hat. So oder so meldet docker inspect
137, und aus „verklemmt“, das nichts behandelt hat, wird „weg“, das jeder Stack
längst behandelt:
| Stack | Was den Worker ersetzt |
|---|---|
docker-compose.yml |
der Reload-Supervisor, bei seiner nächsten Abfrage |
docker-compose-dev.yml |
PID 1 ist der Server, der Container endet also und restart: unless-stopped greift |
docker-compose-prod.yml |
Multiprocess von uvicorn, innerhalb einer knappen halben Sekunde; die anderen drei Worker bedienen weiter |
Zwei Eigenschaften sind der Grund für das Design, und beide lohnen sich zu wissen, bevor die Zahl geändert wird:
- Sie misst Liveness, nicht Readiness. Der Stempel ist ein Timer-Callback und keine Anfrage, eine langsame Datenbank oder ein Model Provider, der zwanzig Sekunden braucht, ist also keine Verklemmung — der Loop dreht sich, er wartet. Eine HTTP-Probe wären weniger bewegliche Teile gewesen und würde einen gesunden Server gegen eine kaputte Abhängigkeit in eine Neustartschleife schicken.
- Zwei Prüfungen, nicht eine.
docker pause, eine eingefrorene cgroup und ein Laptop, der aus dem Schlaf erwacht, halten den Watchdog ebenso gründlich an wie den Loop, die erste Prüfung danach liest also einen veralteten Stempel, der nichts sagt.
Der Reload-Supervisor des lokalen Stacks liest dieselbe Variable für das Urteil, das er von außerhalb des Workers fällt, eine Zahl deckt also beides ab.
Setzen Sie sie beim Debuggen auf 0
Ein Breakpoint blockiert den Event Loop, und nichts kann das von einem Deadlock unterscheiden, ein Worker, der auf einem sitzt, wird Ihnen sonst unter den Händen getötet.
Sie kann einen Prozess nicht sehen, der überhaupt nicht läuft — kill -STOP, eine
eingefrorene cgroup —, weil ein Watchdog in einem angehaltenen Prozess ebenfalls
angehalten ist. Diesen Fall decken die Supervisoren bereits ab: Der Takt des
Reload-Supervisors wird schal und der Pipe-Ping der Produktion bleibt unbeantwortet.
Dieser Ping wartet UVICORN_WORKER_HEALTHCHECK_TIMEOUT Sekunden, standardmäßig
60. Uvicorns eigener Standard ist 5, und ein Host, dem CPU fehlt — Steal auf einem
geteilten VPS, ein Image-Build, ein Rückstau an Flow-Läufen —, verpasst 5 Sekunden
mit Workern, die leben und bedienen.
Der Supervisor tötet sie dann und tötet ihre
Nachfolger, bevor sie die Anwendung fertig importiert haben, und jeder Neustart
legt einen Import auf den Druck, der ihn verursacht hat. Im Log erscheint das als
Child process [N] died ohne Watchdog-Zeile darüber. Da ein blockierter Loop
Sache des Watchdogs ist, muss der Ping nur einen angehaltenen Prozess bemerken,
und dafür reicht eine Minute.
Docker / Produktion¶
| Variable | Standard | Beschreibung |
|---|---|---|
DOMAIN |
example.com |
Produktionsdomain (für Traefik) |
ACME_EMAIL |
admin@example.com |
Let's-Encrypt-Adresse für SSL-Zertifikate |
REDIS_PASSWORD |
change-me-in-production |
Redis-Passwort für die Produktion |
Checkliste für die Produktion¶
Jedes davon wird mit einem Standardwert ausgeliefert, der in der Produktion falsch ist
Ein Deployment, das von anderswo erreichbar ist, hat alle neun bewusst gesetzt.
- [ ]
SECRET_KEY— ein eindeutiger Hex-Key mit 64 Zeichen:openssl rand -hex 32 - [ ]
API_KEY— ein eindeutiger Key:openssl rand -hex 32 - [ ]
VAULT_MASTER_KEY— ein eindeutiger Key:openssl rand -hex 32. Die Konfiguration lehnt einen leeren außerhalb vonlocal/developmentab - [ ]
ENVIRONMENT—production - [ ]
DEBUG—false - [ ]
POSTGRES_PASSWORD— ein starkes, eindeutiges Passwort - [ ]
REDIS_PASSWORD— ein starkes Passwort - [ ]
CORS_ORIGINS— nur Ihre tatsächlichen Frontend-Domains
E-Mail steht bewusst nicht auf dieser Liste: Ein Deployment läuft auch ohne.
Aber Einladungen, Passwort-Zurücksetzungen und Benachrichtigungen bleiben still
ungesendet, bis SMTP_HOST und der Rest von E-Mail (SMTP) auf
einen echten Server zeigen — ein Deployment, das darauf verzichtet, sollte das
also wissentlich tun.