2c03fefa9d
Третий класс петли (инцидент 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>
185 lines
6.4 KiB
TypeScript
185 lines
6.4 KiB
TypeScript
import { EnvironmentService } from './environment.service';
|
|
|
|
// Direct instantiation with a stub ConfigService, mirroring the rest of these
|
|
// unit specs.
|
|
describe('EnvironmentService', () => {
|
|
let service: EnvironmentService;
|
|
|
|
beforeEach(() => {
|
|
service = new EnvironmentService(
|
|
{} as any, // configService
|
|
);
|
|
});
|
|
|
|
it('should be defined', () => {
|
|
expect(service).toBeDefined();
|
|
});
|
|
|
|
describe('getSandboxTtlMs', () => {
|
|
// ConfigService stub: get(key, def) returns the configured value for the key
|
|
// (falling back to def), matching the @nestjs/config contract the service
|
|
// calls with (key, default).
|
|
const build = (sandboxTtl?: string) =>
|
|
new EnvironmentService({
|
|
get: (key: string, def?: string) =>
|
|
key === 'SANDBOX_TTL_MS' ? (sandboxTtl ?? def) : def,
|
|
} as any);
|
|
|
|
it.each(['0', '-5', 'abc'])(
|
|
'falls back to the 3600000 default for invalid value %s',
|
|
(value) => {
|
|
expect(build(value).getSandboxTtlMs()).toBe(3_600_000);
|
|
},
|
|
);
|
|
|
|
it('returns the parsed value for a valid positive integer', () => {
|
|
expect(build('120000').getSandboxTtlMs()).toBe(120_000);
|
|
});
|
|
|
|
it('uses the 3600000 default when SANDBOX_TTL_MS is unset', () => {
|
|
expect(build(undefined).getSandboxTtlMs()).toBe(3_600_000);
|
|
});
|
|
});
|
|
|
|
// The three byte caps share the same getPositiveIntEnv() helper as the TTL,
|
|
// so a non-integer / non-positive value ('0'/'-5'/'abc') falls back to the
|
|
// documented default and a valid positive integer is returned parsed. Note
|
|
// parseInt truncates '1.5' -> 1 (a valid positive integer), so that value is
|
|
// accepted, not rejected — same as the pre-existing TTL getter.
|
|
describe.each([
|
|
{
|
|
name: 'getSandboxMaxBytes',
|
|
key: 'SANDBOX_MAX_BYTES',
|
|
def: 8_388_608,
|
|
getter: (s: EnvironmentService) => s.getSandboxMaxBytes(),
|
|
},
|
|
{
|
|
name: 'getSandboxMaxImageBytes',
|
|
key: 'SANDBOX_MAX_IMAGE_BYTES',
|
|
def: 20_971_520,
|
|
getter: (s: EnvironmentService) => s.getSandboxMaxImageBytes(),
|
|
},
|
|
{
|
|
name: 'getSandboxMaxTotalBytes',
|
|
key: 'SANDBOX_MAX_TOTAL_BYTES',
|
|
def: 134_217_728,
|
|
getter: (s: EnvironmentService) => s.getSandboxMaxTotalBytes(),
|
|
},
|
|
])('$name', ({ key, def, getter }) => {
|
|
// ConfigService stub: get(k, d) returns the configured value for THIS cap's
|
|
// key (falling back to d), and the default for every other key.
|
|
const build = (value?: string) =>
|
|
new EnvironmentService({
|
|
get: (k: string, d?: string) =>
|
|
k === key ? (value ?? d) : d,
|
|
} as any);
|
|
|
|
it.each(['0', '-5', 'abc'])(
|
|
`falls back to the ${def} default for invalid value %s`,
|
|
(value) => {
|
|
expect(getter(build(value))).toBe(def);
|
|
},
|
|
);
|
|
|
|
it('returns the parsed value for a valid positive integer', () => {
|
|
expect(getter(build('4096'))).toBe(4096);
|
|
});
|
|
|
|
it('truncates a non-integer like "1.5" to 1 via parseInt (not rejected)', () => {
|
|
expect(getter(build('1.5'))).toBe(1);
|
|
});
|
|
|
|
it(`uses the ${def} default when the env is unset`, () => {
|
|
expect(getter(build(undefined))).toBe(def);
|
|
});
|
|
});
|
|
|
|
// getPositiveIntEnv keeps a one-shot `invalidPositiveIntWarned` set so a bad
|
|
// value is logged ONCE per key (not on every getter call, which the sandbox
|
|
// hits per-put). These tests pin that dedup so a regression to per-call logging
|
|
// would fail loudly.
|
|
describe('invalid-value warn dedup', () => {
|
|
it('warns only once per key across repeated getter calls', () => {
|
|
const service = new EnvironmentService({
|
|
get: (k: string, d?: string) =>
|
|
k === 'SANDBOX_MAX_TOTAL_BYTES' ? '-5' : d,
|
|
} as any);
|
|
const warnSpy = jest
|
|
.spyOn((service as any).logger, 'warn')
|
|
.mockImplementation(() => undefined);
|
|
|
|
service.getSandboxMaxTotalBytes();
|
|
service.getSandboxMaxTotalBytes();
|
|
|
|
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('warns independently per key (dedup is per-key, not global)', () => {
|
|
// Two DIFFERENT SANDBOX_* keys are both invalid -> each warns once, so two
|
|
// warns total. This proves the dedup set is keyed, not a single global flag.
|
|
const service = new EnvironmentService({
|
|
get: (k: string, d?: string) =>
|
|
k === 'SANDBOX_MAX_BYTES' || k === 'SANDBOX_MAX_TOTAL_BYTES'
|
|
? '-5'
|
|
: d,
|
|
} as any);
|
|
const warnSpy = jest
|
|
.spyOn((service as any).logger, 'warn')
|
|
.mockImplementation(() => undefined);
|
|
|
|
service.getSandboxMaxBytes();
|
|
service.getSandboxMaxTotalBytes();
|
|
|
|
expect(warnSpy).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe('getSandboxPublicUrl', () => {
|
|
// Stub that resolves BOTH keys the public-url logic consults.
|
|
const build = (vals: { sandboxUrl?: string; appUrl?: string }) =>
|
|
new EnvironmentService({
|
|
get: (key: string, def?: string) =>
|
|
key === 'SANDBOX_PUBLIC_URL'
|
|
? (vals.sandboxUrl ?? def)
|
|
: key === 'APP_URL'
|
|
? (vals.appUrl ?? def)
|
|
: def,
|
|
} as any);
|
|
|
|
it('uses SANDBOX_PUBLIC_URL and trims a trailing slash', () => {
|
|
expect(
|
|
build({ sandboxUrl: 'https://docs.example.com/' }).getSandboxPublicUrl(),
|
|
).toBe('https://docs.example.com');
|
|
});
|
|
|
|
it('falls back to APP_URL (origin) when SANDBOX_PUBLIC_URL is unset', () => {
|
|
expect(
|
|
build({ appUrl: 'https://app.example.com' }).getSandboxPublicUrl(),
|
|
).toBe('https://app.example.com');
|
|
});
|
|
});
|
|
|
|
describe('isAiChatFinalStepLockdownEnabled (#444)', () => {
|
|
const build = (val?: string) =>
|
|
new EnvironmentService({
|
|
get: (key: string, def?: string) =>
|
|
key === 'AI_CHAT_FINAL_STEP_LOCKDOWN' ? (val ?? def) : def,
|
|
} as any);
|
|
|
|
it('defaults to OFF (false) when unset — the new anti-degeneration default', () => {
|
|
expect(build(undefined).isAiChatFinalStepLockdownEnabled()).toBe(false);
|
|
});
|
|
|
|
it('is true only for the exact opt-in "true" (case-insensitive)', () => {
|
|
expect(build('true').isAiChatFinalStepLockdownEnabled()).toBe(true);
|
|
expect(build('TRUE').isAiChatFinalStepLockdownEnabled()).toBe(true);
|
|
});
|
|
|
|
it('stays OFF for any other value', () => {
|
|
expect(build('false').isAiChatFinalStepLockdownEnabled()).toBe(false);
|
|
expect(build('1').isAiChatFinalStepLockdownEnabled()).toBe(false);
|
|
expect(build('yes').isAiChatFinalStepLockdownEnabled()).toBe(false);
|
|
});
|
|
});
|
|
});
|