Files
gitmost/apps/server/src/integrations/environment/environment.service.ts
T
agent_coder 2c03fefa9d fix(ai-chat): защита от петель агента — lockdown под тоггл, детектор деградации, бюджет шагов (#444)
Третий класс петли (инцидент 2026-07-10): ран упёрся в 20-шаговый кап, спалив
все шаги на чтение; на 20-м шаге final-step lockdown отнял инструменты
(toolChoice:'none') посреди незаконченной работы → модель выродилась в
текст-повтор («loadTools.» ×20416, 255КБ). Пакет защит по дизайну владельца:

- MAX_AGENT_STEPS 20→50; спеки выводятся из константы (нет захардкоженных 19/20).
- Final-step lockdown под env-тогглом AI_CHAT_FINAL_STEP_LOCKDOWN (дефолт OFF,
  по образцу AI_CHAT_DEFERRED_TOOLS): OFF — инструменты доступны на всех шагах +
  мягкий финальный нудж; ON — легаси toolChoice:'none'+FINAL_STEP_INSTRUCTION.
  Спеки параметризованы по тогглу. .env.example задокументирован.
- Пустой ход (все шаги без текста, шаги исчерпаны) получает синтетический
  маркер-текст — виден в UI и реплее.
- Детектор токен-деградации в onChunk (единственная защита от болтовни, БЕЗ
  maxOutputTokens — tool-аргументы это выходные токены): чистые правила
  (≥25 одинаковых строк ИЛИ периодический хвост), при срабатывании abort через
  внутренний AbortController ∪ effectiveSignal (AbortSignal.any), финализация в
  onAbort: усечение хвоста, ai_chat_runs.error=Output degeneration detected,
  лизы MCP/снапшоты освобождаются (существующий lifecycle).
- Предупреждение о бюджете шагов на MAX-6…MAX-2 с убывающим N.
- loadTools-описание и преамбула каталога явно говорят, что CORE-тулы всегда
  активны (список из CORE_TOOL_KEYS динамически).

Внутренний цикл: 2 прохода. Ревью нашло data-loss-риск: правило периодичности
детектора ложно срабатывало на markdown-разделителях/setext-подчёркиваниях/
хвостовых пробелах (монохар-хвост p-периодичен при ЛЮБОМ p → ложный abort с
пометкой error и усечением). Починка: отдельная монохар-проверка (порог 60,
выше любого реального разделителя) + требование ≥2 различных символов в
периодическом блоке при p≥2. Реальный loadTools-цикл (период ~10) ловится.
Мутационно: 59 одинаковых — не флаг, 60 — флаг; loadTools×20416 — флаг.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:28:48 +03:00

467 lines
15 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import ms, { StringValue } from 'ms';
@Injectable()
export class EnvironmentService {
private readonly logger = new Logger(EnvironmentService.name);
// Env keys already warned about for an invalid value (one-shot per key, so a
// bad SANDBOX_* value is not logged on every blob put). Mirrors the original
// sandboxTtlWarned guard, generalized across the TTL + the three byte caps.
private readonly invalidPositiveIntWarned = new Set<string>();
constructor(private configService: ConfigService) {}
getNodeEnv(): string {
return this.configService.get<string>('NODE_ENV', 'development');
}
isDevelopment(): boolean {
return this.getNodeEnv() === 'development';
}
getAppUrl(): string {
const rawUrl =
this.configService.get<string>('APP_URL') ||
`http://localhost:${this.getPort()}`;
const { origin } = new URL(rawUrl);
return origin;
}
isHttps(): boolean {
const appUrl = this.configService.get<string>('APP_URL');
try {
const url = new URL(appUrl);
return url.protocol === 'https:';
} catch (error) {
return false;
}
}
getSubdomainHost(): string {
return this.configService.get<string>('SUBDOMAIN_HOST');
}
getPort(): number {
return parseInt(this.configService.get<string>('PORT', '3000'));
}
getAppSecret(): string {
return this.configService.get<string>('APP_SECRET');
}
getDatabaseURL(): string {
return this.configService.get<string>('DATABASE_URL');
}
getDatabaseMaxPool(): number {
return parseInt(this.configService.get<string>('DATABASE_MAX_POOL', '10'));
}
getRedisUrl(): string {
return this.configService.get<string>(
'REDIS_URL',
'redis://localhost:6379',
);
}
getJwtTokenExpiresIn(): string {
return this.configService.get<string>('JWT_TOKEN_EXPIRES_IN', '90d');
}
getCookieExpiresIn(): Date {
const expiresInStr = this.getJwtTokenExpiresIn();
let msUntilExpiry: number;
try {
msUntilExpiry = ms(expiresInStr as StringValue);
} catch (err) {
msUntilExpiry = ms('90d');
}
return new Date(Date.now() + msUntilExpiry);
}
getGotenbergUrl(): string | undefined {
return this.configService.get<string>('GOTENBERG_URL');
}
getStorageDriver(): string {
return this.configService.get<string>('STORAGE_DRIVER', 'local');
}
getFileUploadSizeLimit(): string {
return this.configService.get<string>('FILE_UPLOAD_SIZE_LIMIT', '50mb');
}
getFileImportSizeLimit(): string {
return this.configService.get<string>('FILE_IMPORT_SIZE_LIMIT', '200mb');
}
getAwsS3AccessKeyId(): string {
return this.configService.get<string>('AWS_S3_ACCESS_KEY_ID');
}
getAwsS3SecretAccessKey(): string {
return this.configService.get<string>('AWS_S3_SECRET_ACCESS_KEY');
}
getAwsS3Region(): string {
return this.configService.get<string>('AWS_S3_REGION');
}
getAwsS3Bucket(): string {
return this.configService.get<string>('AWS_S3_BUCKET');
}
getAwsS3Endpoint(): string {
return this.configService.get<string>('AWS_S3_ENDPOINT');
}
getAwsS3ForcePathStyle(): boolean {
const forcePathStyle = this.configService
.get<string>('AWS_S3_FORCE_PATH_STYLE', 'false')
.toLowerCase();
return forcePathStyle === 'true';
}
getAwsS3Url(): string {
return this.configService.get<string>('AWS_S3_URL');
}
getAzureStorageAccountName(): string {
return this.configService.get<string>('AZURE_STORAGE_ACCOUNT_NAME');
}
getAzureStorageContainer(): string {
return this.configService.get<string>('AZURE_STORAGE_CONTAINER');
}
getAzureStorageAccountKey(): string {
return this.configService.get<string>('AZURE_STORAGE_ACCOUNT_KEY');
}
getAzureStorageEndpoint(): string {
return this.configService.get<string>('AZURE_STORAGE_ENDPOINT');
}
getAzureStorageUrl(): string {
return this.configService.get<string>('AZURE_STORAGE_URL');
}
getMailDriver(): string {
return this.configService.get<string>('MAIL_DRIVER', 'log');
}
getMailFromAddress(): string {
return this.configService.get<string>('MAIL_FROM_ADDRESS');
}
getMailFromName(): string {
return this.configService.get<string>('MAIL_FROM_NAME', 'Docmost');
}
getMailBlockedRecipientDomains(): string[] {
const raw = this.configService.get<string>(
'MAIL_BLOCKED_RECIPIENT_DOMAINS',
'',
);
return raw
.split(',')
.map((d) => d.trim().toLowerCase())
.filter(Boolean);
}
getSmtpHost(): string {
return this.configService.get<string>('SMTP_HOST');
}
getSmtpPort(): number {
return parseInt(this.configService.get<string>('SMTP_PORT'));
}
getSmtpSecure(): boolean {
const secure = this.configService
.get<string>('SMTP_SECURE', 'false')
.toLowerCase();
return secure === 'true';
}
getSmtpIgnoreTLS(): boolean {
const ignoretls = this.configService
.get<string>('SMTP_IGNORETLS', 'false')
.toLowerCase();
return ignoretls === 'true';
}
getSmtpUsername(): string {
return this.configService.get<string>('SMTP_USERNAME');
}
getSmtpPassword(): string {
return this.configService.get<string>('SMTP_PASSWORD');
}
getPostmarkToken(): string {
return this.configService.get<string>('POSTMARK_TOKEN');
}
getDrawioUrl(): string {
return this.configService.get<string>('DRAWIO_URL');
}
isCloud(): boolean {
const cloudConfig = this.configService
.get<string>('CLOUD', 'false')
.toLowerCase();
return cloudConfig === 'true';
}
isSelfHosted(): boolean {
return !this.isCloud();
}
isCompactPageTreeEnabled(): boolean {
const compactTree = this.configService
.get<string>('COMPACT_PAGE_TREE', 'true')
.toLowerCase();
return compactTree === 'true';
}
/**
* Operator toggle for the public client-telemetry sink (#355). DEFAULT OFF:
* the unauthenticated POST /api/telemetry/vitals endpoint + client vitals
* collection are only wired when this is explicitly true. Kept SEPARATE from
* METRICS_PORT (the server Prometheus half) because Grafana reads the
* `client_metrics` table directly, independent of the scrape port — and
* because `client_metrics` has no app-side retention, so an operator must opt
* in and run an external pruner.
*/
isClientTelemetryEnabled(): boolean {
const enabled = this.configService
.get<string>('CLIENT_TELEMETRY_ENABLED', 'false')
.toLowerCase();
return enabled === 'true';
}
getStripePublishableKey(): string {
return this.configService.get<string>('STRIPE_PUBLISHABLE_KEY');
}
getStripeSecretKey(): string {
return this.configService.get<string>('STRIPE_SECRET_KEY');
}
getStripeWebhookSecret(): string {
return this.configService.get<string>('STRIPE_WEBHOOK_SECRET');
}
getBillingTrialDays(): number {
return parseInt(this.configService.get<string>('BILLING_TRIAL_DAYS', '14'));
}
getCollabUrl(): string {
return this.configService.get<string>('COLLAB_URL');
}
isCollabDisableRedis(): boolean {
const isStandalone = this.configService
.get<string>('COLLAB_DISABLE_REDIS', 'false')
.toLowerCase();
return isStandalone === 'true';
}
isDisableTelemetry(): boolean {
const disable = this.configService
.get<string>('DISABLE_TELEMETRY', 'false')
.toLowerCase();
return disable === 'true';
}
/**
* Deferred tool loading for the in-app AI chat (#332). When enabled, the agent
* sees a compact <tool_catalog> and only CORE tools + the loadTools meta-tool
* are active each step; deferred tools (the fat/rare ones + all external MCP
* tools) load on demand. Defaults to ENABLED — the issue treats deferred
* loading as the new behavior; set AI_CHAT_DEFERRED_TOOLS=false to restore the
* old "all tools always active" behavior.
*/
isAiChatDeferredToolsEnabled(): boolean {
const enabled = this.configService
.get<string>('AI_CHAT_DEFERRED_TOOLS', 'true')
.toLowerCase();
return enabled === 'true';
}
/**
* Final-step lockdown for the in-app agent loop (#444). When ON (legacy), the
* LAST allowed step forces a text-only answer: tools are stripped
* (toolChoice:'none') and a synthesis instruction is appended. Defaults to OFF:
* stripping the tools mid-work triggered a token-loop degeneration incident
* (the model, robbed of its tools on the final step, emitted a 255KB block
* repeating a single token). With the toggle OFF the last step keeps its tools
* and gets only a SOFT nudge to finish with a text summary; the universal
* anti-babble guard is the token-degeneration detector instead. Enable this
* only for a model that does NOT reliably end its turns with a text answer.
*/
isAiChatFinalStepLockdownEnabled(): boolean {
const enabled = this.configService
.get<string>('AI_CHAT_FINAL_STEP_LOCKDOWN', 'false')
.toLowerCase();
return enabled === 'true';
}
/**
* Resumable SSE transport for durable agent runs (#184 phase 1.5). When
* enabled, a run tees its SSE frames into the in-memory run-stream registry so
* a late/reloaded tab can attach (replay + live tail) via
* `GET /ai-chat/runs/:chatId/stream`. Defaults to DISABLED: PR 1 ships the
* server code dormant — with the flag off, `open`/`bind`/`generateMessageId`
* are never called and attach always answers 204, so the legacy and #184
* phase-1 wire paths stay byte-for-byte identical. Set
* AI_CHAT_RESUMABLE_STREAM=true to activate it (paired with the PR 2 client).
*/
isAiChatResumableStreamEnabled(): boolean {
const enabled = this.configService
.get<string>('AI_CHAT_RESUMABLE_STREAM', 'false')
.toLowerCase();
return enabled === 'true';
}
getPostHogHost(): string {
return this.configService.get<string>('POSTHOG_HOST');
}
getPostHogKey(): string {
return this.configService.get<string>('POSTHOG_KEY');
}
getSearchDriver(): string {
return this.configService
.get<string>('SEARCH_DRIVER', 'database')
.toLowerCase();
}
getTypesenseUrl(): string {
return this.configService
.get<string>('TYPESENSE_URL', 'http://localhost:8108')
.toLowerCase();
}
getTypesenseApiKey(): string {
return this.configService.get<string>('TYPESENSE_API_KEY');
}
getTypesenseLocale(): string {
return this.configService
.get<string>('TYPESENSE_LOCALE', 'en')
.toLowerCase();
}
// NOTE: AI_*/OPENAI_*/GEMINI_*/OLLAMA_* env getters were removed (D8/§14[M3]):
// provider/model/key config now lives solely in workspace settings +
// ai_provider_credentials, with no env fallback. APP_SECRET stays (getAppSecret).
getAiAgentRolesCatalogSource(): string {
// Catalog location: an http(s):// base URL the catalog is fetched from.
// The image ships a per-branch default for this baked in at build time
// (Dockerfile ARG AI_AGENT_ROLES_CATALOG_URL, set per-branch in CI), but it
// is overridable at runtime via the env var (this getter returns that
// runtime value). Local-filesystem sources are no longer supported.
// Empty/unset => the catalog is unavailable (the provider returns 502).
// This is INFRA config (where the catalog lives), not provider/model
// config, so an env var is appropriate.
return this.configService.get<string>('AI_AGENT_ROLES_CATALOG_URL', '');
}
getEventStoreDriver(): string {
return this.configService
.get<string>('EVENT_STORE_DRIVER', 'postgres')
.toLowerCase();
}
getClickHouseUrl(): string {
return this.configService.get<string>('CLICKHOUSE_URL');
}
getSamlDisableRequestedAuthnContext(): boolean {
const disabled = this.configService
.get<string>('SAML_DISABLE_REQUESTED_AUTHN_CONTEXT', 'false')
.toLowerCase();
return disabled === 'true';
}
isIframeEmbedAllowed(): boolean {
const allowed = this.configService
.get<string>('IFRAME_EMBED_ALLOWED', 'false')
.toLowerCase();
return allowed === 'true';
}
getIframeAllowedOrigins(): string[] {
const raw = this.configService.get<string>('IFRAME_ALLOWED_ORIGINS', '');
return raw
.split(',')
.map((o) => o.trim())
.filter(Boolean);
}
// --- Blob sandbox (in-RAM ephemeral blob transfer; see SandboxModule) ---
// Base URL the sandbox `uri` is built from. It MUST be reachable over the
// network by the external consumer that fetches the blobs (not a loopback
// address if that consumer is remote). Falls back to APP_URL when unset so a
// single-host deployment works out of the box; set it explicitly when the
// consumer lives on another host.
getSandboxPublicUrl(): string {
const raw =
this.configService.get<string>('SANDBOX_PUBLIC_URL') || this.getAppUrl();
// Drop any trailing slash so `${base}/api/sb/${id}` never doubles up.
return raw.replace(/\/+$/, '');
}
// Parse a REQUIRED positive-integer env (TTL in ms or a byte cap). A
// non-integer or <= 0 value would break the sandbox silently (instant expiry,
// or every put failing against a 0-byte cap), so warn once and fall back to
// the default instead. Blob bodies are never logged.
private getPositiveIntEnv(key: string, def: number): number {
const parsed = parseInt(
this.configService.get<string>(key, String(def)),
10,
);
if (!Number.isInteger(parsed) || parsed <= 0) {
if (!this.invalidPositiveIntWarned.has(key)) {
this.invalidPositiveIntWarned.add(key);
this.logger.warn(
`Invalid ${key} (must be a positive integer); falling back to the ${def} default`,
);
}
return def;
}
return parsed;
}
// Blob time-to-live. Default 1h. The unguessable UUID + this short TTL + TLS
// are the whole capability model (no tokens). A non-positive or non-integer
// value would make every blob expire instantly (silent 404s), so reject it and
// fall back to the 1h default (warned about once to avoid per-put log spam).
getSandboxTtlMs(): number {
return this.getPositiveIntEnv('SANDBOX_TTL_MS', 3_600_000);
}
// Per-blob cap for non-image blobs (the serialized document). Default 8 MiB.
getSandboxMaxBytes(): number {
return this.getPositiveIntEnv('SANDBOX_MAX_BYTES', 8_388_608);
}
// Per-blob cap for mirrored image blobs. Default 20 MiB.
getSandboxMaxImageBytes(): number {
return this.getPositiveIntEnv('SANDBOX_MAX_IMAGE_BYTES', 20_971_520);
}
// RAM guard: total bytes the whole store may hold. Default 128 MiB. On
// overflow the store evicts oldest entries to make room.
getSandboxMaxTotalBytes(): number {
return this.getPositiveIntEnv('SANDBOX_MAX_TOTAL_BYTES', 134_217_728);
}
}