/** * Tests for the v1 → v2 config normalizer (Step 1 of the 2026-05-21 * Settings UI / Config Restructure design). * * Coverage matrix: * - explicit v2 input → pass through (no warning, no migration) * - explicit `config_version: 99` → fatal * - v1 provider.workers[].proxy = true → connection_type aao_gateway * - v1 provider.workers[].proxy = false/missing → connection_type direct * - provider.model fills missing worker.model * - both empty → empty model + warning (not fatal) * - storage.* mirrors of worktree_dir / custom_pieces_dir / user_folder_root / * tools.task_upload_max_size_mb / tools.trash_retention_days * - `${VAR}` references preserved verbatim (not coerced into literals) * - fixture-driven snapshots for the four documented production shapes */ import { describe, expect, it } from 'vitest'; import { readFileSync } from 'fs'; import { join } from 'path'; import { parse as parseYaml } from 'yaml'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; import { normalizeConfig, UnsupportedConfigVersionError, } from './config-normalize.js'; import { toSnakeKeys } from './config.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const FIXTURES_DIR = join(__dirname, '__fixtures__', 'config-migration'); /** * Tiny helper: parse a YAML string and pre-camelCase keys the way loadConfig * does. The normalizer expects camelCased keys (post-transformKeys). */ function loadYaml(yamlText: string): unknown { return transformToCamel(parseYaml(yamlText)); } function loadFixture(name: string): unknown { return loadYaml(readFileSync(join(FIXTURES_DIR, name), 'utf-8')); } /** snake_case → camelCase recursive (mirror of config.ts transformKeys). */ function transformToCamel(obj: unknown): unknown { if (Array.isArray(obj)) return obj.map(transformToCamel); if (obj !== null && typeof obj === 'object') { return Object.fromEntries( Object.entries(obj as Record).map(([k, v]) => [ k.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()), transformToCamel(v), ]), ); } return obj; } describe('normalizeConfig — version handling', () => { it('v2 input passes through with config_version=2', () => { const out = normalizeConfig({ configVersion: 2, llm: { workers: [ { id: 'w1', connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'qwen3:32b', roles: ['auto'], maxConcurrency: 1, enabled: true, }, ], }, }); expect(out.configVersion).toBe(2); expect(out.llm?.workers).toHaveLength(1); expect(out.llm?.workers[0]).toMatchObject({ id: 'w1', connectionType: 'direct', model: 'qwen3:32b', }); }); it('missing config_version treated as v1', () => { const out = normalizeConfig({ provider: { baseUrl: 'http://localhost:11434/v1', model: 'qwen3:32b', workers: [], }, }); expect(out.configVersion).toBe(2); // v1 → defaulted single worker from baseUrl expect(out.llm?.workers).toHaveLength(1); expect(out.llm?.workers[0]?.endpoint).toBe('http://localhost:11434/v1'); }); it('config_version: 99 throws UnsupportedConfigVersionError', () => { expect(() => normalizeConfig({ configVersion: 99 })).toThrowError(UnsupportedConfigVersionError); }); it('config_version: "2" (string) throws (typo guard)', () => { expect(() => normalizeConfig({ configVersion: '2' })).toThrowError(UnsupportedConfigVersionError); }); it('config_version: 0 throws', () => { expect(() => normalizeConfig({ configVersion: 0 })).toThrowError(UnsupportedConfigVersionError); }); it('null / non-object input is treated as empty config', () => { const out = normalizeConfig(null); expect(out.configVersion).toBe(2); expect(out.llm?.workers).toEqual([]); }); }); describe('normalizeConfig — v1 provider → v2 llm', () => { it('proxy: true → connection_type: aao_gateway', () => { const out = normalizeConfig({ provider: { model: 'qwen3:8b', workers: [ { id: 'team-pool', endpoint: 'http://litellm:4000/v1', proxy: true, apiKey: 'tok-abc', }, ], }, }); expect(out.llm?.workers[0]).toMatchObject({ id: 'team-pool', connectionType: 'aao_gateway', endpoint: 'http://litellm:4000/v1', apiKey: 'tok-abc', }); }); it('proxy: false → connection_type: direct', () => { const out = normalizeConfig({ provider: { model: 'qwen3:8b', workers: [ { id: 'gpu1', endpoint: 'http://gpu1/v1', proxy: false }, ], }, }); expect(out.llm?.workers[0]?.connectionType).toBe('direct'); }); it('proxy omitted → connection_type: direct', () => { const out = normalizeConfig({ provider: { model: 'qwen3:8b', workers: [{ id: 'gpu1', endpoint: 'http://gpu1/v1' }], }, }); expect(out.llm?.workers[0]?.connectionType).toBe('direct'); }); it('worker.model empty + provider.model set → worker.model inherits', () => { const out = normalizeConfig({ provider: { model: 'qwen3:32b', workers: [{ id: 'gpu1', endpoint: 'http://gpu1/v1' }], }, }); expect(out.llm?.workers[0]?.model).toBe('qwen3:32b'); }); it('worker.model explicit overrides provider.model', () => { const out = normalizeConfig({ provider: { model: 'qwen3:32b', workers: [ { id: 'gpu1', endpoint: 'http://gpu1/v1', model: 'qwen3:14b' }, ], }, }); expect(out.llm?.workers[0]?.model).toBe('qwen3:14b'); }); it('worker.model empty + provider.model empty → empty string, no throw', () => { expect(() => normalizeConfig({ provider: { workers: [{ id: 'gpu1', endpoint: 'http://gpu1/v1' }] }, }), ).not.toThrow(); const out = normalizeConfig({ provider: { workers: [{ id: 'gpu1', endpoint: 'http://gpu1/v1' }] }, }); expect(out.llm?.workers[0]?.model).toBe(''); }); it('base_url with no workers → single default worker', () => { const out = normalizeConfig({ provider: { baseUrl: 'http://localhost:11434/v1', model: 'qwen3:32b', workers: [], }, }); expect(out.llm?.workers).toHaveLength(1); expect(out.llm?.workers[0]).toMatchObject({ id: 'default', endpoint: 'http://localhost:11434/v1', model: 'qwen3:32b', connectionType: 'direct', }); }); it('provider.timeout_minutes / retry / metrics → llm.*', () => { const out = normalizeConfig({ provider: { model: 'qwen3:8b', baseUrl: 'http://localhost:11434/v1', timeoutMinutes: 20, retry: { maxAttempts: 5, backoffMs: [1000, 2000], retryableStatus: [429, 503], }, metrics: { enabled: true, prefix: 'aao_worker' }, }, }); expect(out.llm?.timeoutMinutes).toBe(20); expect(out.llm?.retry).toEqual({ maxAttempts: 5, backoffMs: [1000, 2000], retryableStatus: [429, 503], }); expect(out.llm?.metrics).toEqual({ enabled: true, prefix: 'aao_worker' }); }); it('profiles: [...] → roles: [...] on v2 worker', () => { const out = normalizeConfig({ provider: { model: 'm', workers: [ // Use camelCased keys (post-transformKeys) { id: 'g', endpoint: 'http://g/v1', profiles: ['fast'] }, ], }, }); expect(out.llm?.workers[0]?.roles).toEqual(['fast']); }); it('roles defaults to [auto, fast, quality] when neither roles nor profiles set', () => { const out = normalizeConfig({ provider: { model: 'm', workers: [{ id: 'g', endpoint: 'http://g/v1' }], }, }); expect(out.llm?.workers[0]?.roles).toEqual(['auto', 'fast', 'quality']); }); }); describe('normalizeConfig — storage migration', () => { it('top-level worktree_dir → storage.worktreeDir', () => { const out = normalizeConfig({ worktreeDir: '/data/workspaces', provider: { workers: [{ id: 'g', endpoint: 'http://g/v1', model: 'm' }] }, }); expect(out.storage?.worktreeDir).toBe('/data/workspaces'); }); it('tools.task_upload_max_size_mb → storage.taskUploadMaxSizeMb', () => { const out = normalizeConfig({ tools: { taskUploadMaxSizeMb: 100 }, provider: { workers: [{ id: 'g', endpoint: 'http://g/v1', model: 'm' }] }, }); expect(out.storage?.taskUploadMaxSizeMb).toBe(100); }); it('tools.trash_retention_days → storage.trashRetentionDays', () => { const out = normalizeConfig({ tools: { trashRetentionDays: 7 }, provider: { workers: [{ id: 'g', endpoint: 'http://g/v1', model: 'm' }] }, }); expect(out.storage?.trashRetentionDays).toBe(7); }); it('all storage keys round-trip together', () => { const out = normalizeConfig({ worktreeDir: '/w', customPiecesDir: './cp', userFolderRoot: './users', tools: { taskUploadMaxSizeMb: 50, trashRetentionDays: 30 }, provider: { workers: [{ id: 'g', endpoint: 'http://g/v1', model: 'm' }] }, }); expect(out.storage).toEqual({ worktreeDir: '/w', customPiecesDir: './cp', userFolderRoot: './users', taskUploadMaxSizeMb: 50, trashRetentionDays: 30, }); }); it('existing storage.* wins over legacy flat keys', () => { const out = normalizeConfig({ worktreeDir: '/old', storage: { worktreeDir: '/new' }, provider: { workers: [{ id: 'g', endpoint: 'http://g/v1', model: 'm' }] }, }); expect(out.storage?.worktreeDir).toBe('/new'); }); }); describe('normalizeConfig — env reference preservation', () => { it('${VAR} in worker api_key is preserved verbatim', () => { const out = normalizeConfig({ provider: { model: 'm', workers: [ { id: 'team', endpoint: 'http://t/v1', proxy: true, apiKey: '${TEAM_KEY}', }, ], }, }); expect(out.llm?.workers[0]?.apiKey).toBe('${TEAM_KEY}'); }); it('${VAR} in provider.metrics.bearer_token is preserved', () => { const out = normalizeConfig({ provider: { model: 'm', baseUrl: 'http://x/v1', metrics: { bearerToken: '${BEARER}' }, }, }); expect(out.llm?.metrics?.bearerToken).toBe('${BEARER}'); }); it('env: prefix is preserved (legacy syntax)', () => { const out = normalizeConfig({ provider: { model: 'm', baseUrl: 'http://x/v1', metrics: { bearerToken: 'env:LEGACY_BEARER' }, }, }); expect(out.llm?.metrics?.bearerToken).toBe('env:LEGACY_BEARER'); }); }); describe('normalizeConfig — fixtures', () => { it('v1-single-ollama.yaml normalizes to expected v2 shape', () => { const raw = loadFixture('v1-single-ollama.yaml'); const out = normalizeConfig(raw); expect(out.configVersion).toBe(2); expect(out.llm?.workers).toHaveLength(1); expect(out.llm?.workers[0]).toMatchObject({ id: 'default', connectionType: 'direct', endpoint: 'http://localhost:11434/v1', model: 'qwen3:32b', enabled: true, maxConcurrency: 1, }); expect(out.llm?.timeoutMinutes).toBe(10); expect(out.llm?.retry).toEqual({ maxAttempts: 3, backoffMs: [2000, 5000, 15000], retryableStatus: [429, 500, 502, 503, 504], }); expect(out.storage).toEqual({ worktreeDir: '/var/lib/agent-orchestrator/workspaces', customPiecesDir: './custom-pieces', userFolderRoot: './data/users', taskUploadMaxSizeMb: 50, trashRetentionDays: 30, }); }); it('v1-multi-worker-with-proxy.yaml maps proxy:true to aao_gateway + keeps ${} refs', () => { const raw = loadFixture('v1-multi-worker-with-proxy.yaml'); const out = normalizeConfig(raw); expect(out.configVersion).toBe(2); expect(out.llm?.timeoutMinutes).toBe(15); expect(out.llm?.workers).toHaveLength(4); const byId = new Map(out.llm!.workers.map(w => [w.id, w])); expect(byId.get('gpu1')).toMatchObject({ connectionType: 'direct', model: 'qwen3:32b', // inherits from provider.model roles: ['auto', 'fast'], maxConcurrency: 2, }); expect(byId.get('gpu2')).toMatchObject({ connectionType: 'direct', model: 'qwen3:14b', // worker override vlm: true, }); expect(byId.get('team-pool')).toMatchObject({ connectionType: 'aao_gateway', apiKey: '${TEAM_A_LITELLM_KEY}', // env ref preserved literally model: 'qwen3:8b', maxConcurrency: 4, roles: ['quality'], }); expect(byId.get('gpu-reflection')).toMatchObject({ connectionType: 'direct', roles: ['reflection'], }); }); it('v1-gateway-server-with-keys.yaml preserves gateway.* untouched + builds llm from provider', () => { const raw = loadFixture('v1-gateway-server-with-keys.yaml'); const out = normalizeConfig(raw); expect(out.configVersion).toBe(2); // provider → llm path expect(out.llm?.workers).toHaveLength(1); expect(out.llm?.workers[0]).toMatchObject({ id: 'default', endpoint: 'http://localhost:11434/v1', model: 'qwen3:8b', connectionType: 'direct', }); expect(out.llm?.metrics?.bearerToken).toBe('${AAO_WORKER_METRICS_BEARER_TOKEN}'); // gateway.* should pass through untouched. const gateway = (out as unknown as { gateway?: Record }).gateway; expect(gateway).toBeDefined(); expect(gateway).toMatchObject({ enabled: true, listenPort: 4000, }); expect(Array.isArray(gateway?.backends)).toBe(true); expect((gateway?.backends as unknown[])).toHaveLength(2); const vkeys = gateway?.virtualKeys as Array>; expect(vkeys[0]?.key).toBe('${TEAM_A_GATEWAY_KEY}'); // env ref kept }); it('v1-mcp-and-ssh.yaml mirrors user_folder_root into storage + preserves mcp/ssh', () => { const raw = loadFixture('v1-mcp-and-ssh.yaml'); const out = normalizeConfig(raw); expect(out.configVersion).toBe(2); expect(out.storage?.userFolderRoot).toBe('/opt/aao/data/users'); expect(out.storage?.taskUploadMaxSizeMb).toBe(100); // mcp + ssh blocks should pass through unchanged expect(out.mcp).toMatchObject({ enabled: true, callTimeoutSeconds: 30 }); expect(out.ssh).toMatchObject({ enabled: true, masterKeyPath: './data/secrets/ssh-master.key', }); // ${VAR} inside tools.knowledge_namespaces stays literal const tools = (out as unknown as { tools?: { knowledgeNamespaces?: Record } }).tools; expect(tools?.knowledgeNamespaces?.eng?.apiKey).toBe('${DKS_ENG_KEY}'); expect(tools?.knowledgeNamespaces?.ops?.apiKey).toBe('${DKS_OPS_KEY}'); }); }); describe('normalizeConfig — backwards compat with loadConfig', () => { it('toSnakeKeys round-trip on normalizer output stays consumable by YAML stringifier', () => { // Sanity guard: the v2 blocks survive snake_case conversion (used by // /api/config write path in later steps). const out = normalizeConfig({ provider: { model: 'qwen3:8b', workers: [ { id: 'gpu1', endpoint: 'http://gpu1/v1', proxy: true, apiKey: '${K}' }, ], }, }); const snake = toSnakeKeys({ config_version: out.configVersion, llm: out.llm, storage: out.storage, }) as Record; expect((snake.llm as Record).workers).toBeDefined(); const workers = (snake.llm as Record).workers as Array>; expect(workers[0]).toMatchObject({ id: 'gpu1', connection_type: 'aao_gateway', api_key: '${K}', }); }); it('mirrors storage.* back into top-level flat keys for legacy readers (2026-05-21 hotfix)', () => { // Production aao broke when v2-only config.yaml dropped top-level // `worktree_dir`. worker-bootstrap.ts:153/172 reads // `config.worktreeDir` and got the default `/var/lib/...` path, // which isn't writable on the typical deploy → EACCES on mkdir. // The normalizer now mirrors storage.* into the legacy top-level // keys so the compat-window readers keep working. const out = normalizeConfig({ configVersion: 2, llm: { workers: [{ id: 'w1', connectionType: 'direct', endpoint: 'http://x/v1', model: 'm' }] }, storage: { worktreeDir: '/home/user/data/agent-workspaces', customPiecesDir: '/home/user/data/pieces', userFolderRoot: '/home/user/data/users', taskUploadMaxSizeMb: 100, trashRetentionDays: 45, }, }); expect(out.worktreeDir).toBe('/home/user/data/agent-workspaces'); expect(out.customPiecesDir).toBe('/home/user/data/pieces'); expect(out.userFolderRoot).toBe('/home/user/data/users'); expect(out.tools?.taskUploadMaxSizeMb).toBe(100); expect(out.tools?.trashRetentionDays).toBe(45); }); it('storage.* explicit value wins over top-level value (#369 precedence fix)', () => { // After hotfix #369: when the source v2 input has an explicit // `storage.worktreeDir`, that value is authoritative and overrides // any pre-existing top-level value — because the top-level is // almost always the legacy default (`/var/lib/...`) merged in by // loadConfig before the normalizer runs. The env override (#369) // is re-applied AFTER normalizeConfig in loadConfig so a runtime // WORKTREE_DIR still wins; that contract isn't tested here. const out = normalizeConfig({ configVersion: 2, llm: { workers: [{ id: 'w1', connectionType: 'direct', endpoint: 'http://x/v1', model: 'm' }] }, worktreeDir: '/var/lib/maestro/workspaces', // simulates merged-in default storage: { worktreeDir: '/home/user/data/agent-workspaces' }, }); expect(out.worktreeDir).toBe('/home/user/data/agent-workspaces'); }); it('v1 top-level worktreeDir survives when no storage block was authored', () => { // Legacy path: a v1 file with `worktree_dir` but no `storage` block // synthesizes storage.worktreeDir from the top-level. The mirror is // a no-op (top-level was already set) and the user's value is // preserved on both sides. const out = normalizeConfig({ // configVersion omitted → v1 path provider: { workers: [{ id: 'w1', endpoint: 'http://x/v1', model: 'm' }] }, worktreeDir: '/home/op/explicit-v1-value', }); expect(out.worktreeDir).toBe('/home/op/explicit-v1-value'); expect(out.storage?.worktreeDir).toBe('/home/op/explicit-v1-value'); }); }); describe('browser.display_mode migration (captcha_solve rename)', () => { it('legacy captchaSolve=novnc → displayMode=novnc, captchaSolve removed', () => { const out = normalizeConfig({ browser: { captchaSolve: 'novnc' } }); expect(out.browser?.displayMode).toBe('novnc'); expect((out.browser as Record)?.captchaSolve).toBeUndefined(); }); it('legacy captchaSolve=skip → displayMode=headless, captchaSolve removed', () => { const out = normalizeConfig({ browser: { captchaSolve: 'skip' } }); expect(out.browser?.displayMode).toBe('headless'); expect((out.browser as Record)?.captchaSolve).toBeUndefined(); }); it('displayMode wins over captchaSolve when both present; captchaSolve removed', () => { const out = normalizeConfig({ browser: { displayMode: 'novnc', captchaSolve: 'skip' } }); expect(out.browser?.displayMode).toBe('novnc'); expect((out.browser as Record)?.captchaSolve).toBeUndefined(); }); it('neither key → browser block unchanged (displayMode stays undefined)', () => { const out = normalizeConfig({ browser: { maxSessions: 3 } }); expect(out.browser?.displayMode).toBeUndefined(); expect((out.browser as Record)?.captchaSolve).toBeUndefined(); expect(out.browser?.maxSessions).toBe(3); }); });