diff --git a/lib/validators/profile-validator.mjs b/lib/validators/profile-validator.mjs index 37909e8..b5fb3a0 100644 --- a/lib/validators/profile-validator.mjs +++ b/lib/validators/profile-validator.mjs @@ -21,7 +21,7 @@ // PROFILE_READ_ERROR — file unreadable or parse-error // PROFILE_NOT_FOUND — file does not exist // -// Allowed model values: ['sonnet', 'opus']. Haiku is allowed only when +// Allowed model values: ['sonnet', 'opus', 'fable']. Haiku is allowed only when // VOYAGE_ALLOW_HAIKU=1 (per global CLAUDE.md modellvalg-prinsipp: Haiku skal // ikke brukes som default; eksplisitt opt-in for spesielle bruksmønstre). @@ -42,7 +42,7 @@ export const PROFILE_REQUIRED_PHASES = Object.freeze([ 'brief', 'research', 'plan', 'execute', 'review', 'continue', ]); -export const BASE_ALLOWED_MODELS = Object.freeze(['sonnet', 'opus']); +export const BASE_ALLOWED_MODELS = Object.freeze(['sonnet', 'opus', 'fable']); function getAllowedModels(env = process.env) { if (env.VOYAGE_ALLOW_HAIKU === '1') { diff --git a/tests/validators/profile-validator.test.mjs b/tests/validators/profile-validator.test.mjs index 37c3806..7cd3fec 100644 --- a/tests/validators/profile-validator.test.mjs +++ b/tests/validators/profile-validator.test.mjs @@ -12,6 +12,7 @@ import { validateProfileContent, PROFILE_REQUIRED_FIELDS, PROFILE_REQUIRED_PHASES, + BASE_ALLOWED_MODELS, } from '../../lib/validators/profile-validator.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -93,6 +94,77 @@ brief_reviewer_iter_cap: 1 `expected valid with VOYAGE_ALLOW_HAIKU=1, got: ${JSON.stringify(allowed.errors)}`); }); +// Fable tier (v5.9): fable accepted under DEFAULT env (no opt-in flag), +// unknown models still rejected — the allowlist gate must demonstrably fire. + +test('fable accepted in phase_models under default env (no env flag)', () => { + const fableProfile = `--- +profile_version: "1.0" +name: fable-inline +phase_models: + - phase: brief + model: fable + - phase: research + model: fable + - phase: plan + model: fable + - phase: execute + model: fable + - phase: review + model: fable + - phase: continue + model: fable +parallel_agents_min: 6 +parallel_agents_max: 8 +external_research_enabled: true +brief_reviewer_iter_cap: 3 +--- +`; + const r = validateProfileContent(fableProfile, { env: { /* default: no flags */ } }); + assert.equal(r.valid, true, + `expected fable accepted under default env, got: ${JSON.stringify(r.errors)}`); + assert.equal(r.errors.length, 0); +}); + +test('unknown model gpt5 rejected with PROFILE_INVALID_MODEL under default env', () => { + const gpt5Profile = `--- +profile_version: "1.0" +name: gpt5-inline +phase_models: + - phase: brief + model: gpt5 + - phase: research + model: sonnet + - phase: plan + model: opus + - phase: execute + model: sonnet + - phase: review + model: opus + - phase: continue + model: sonnet +parallel_agents_min: 2 +parallel_agents_max: 4 +external_research_enabled: false +brief_reviewer_iter_cap: 1 +--- +`; + const r = validateProfileContent(gpt5Profile, { env: { /* default: no flags */ } }); + assert.equal(r.valid, false); + const found = r.errors.find(e => e.code === 'PROFILE_INVALID_MODEL' && /gpt5/.test(e.message)); + assert.ok(found, `expected PROFILE_INVALID_MODEL for gpt5, got: ${JSON.stringify(r.errors)}`); +}); + +// BASE_ALLOWED_MODELS allowlist drift-pin (mirrors the PROFILE_REQUIRED_FIELDS pin) + +test('BASE_ALLOWED_MODELS export drift-pin', () => { + assert.deepEqual( + [...BASE_ALLOWED_MODELS], + ['sonnet', 'opus', 'fable'], + 'BASE_ALLOWED_MODELS contract drift — pin contract', + ); +}); + // Required fields presence test('PROFILE_MISSING_FIELD when name absent', () => {