diff --git a/packages/sdk/src/preflight.ts b/packages/sdk/src/preflight.ts index a16125617..30169f196 100644 --- a/packages/sdk/src/preflight.ts +++ b/packages/sdk/src/preflight.ts @@ -181,12 +181,22 @@ function unknownModelDiagnostics( options: PreflightOptions, ): PreflightRefusal[] { const diagnostics: PreflightRefusal[] = []; + // model_unknown is a governance check: it exists to enforce a project's + // registry-declared allowlist. When no flows.json is found, `check.ts` sends + // `models: []` with `modelRegistryPath: undefined` — an empty list not + // because the project forbids everything, but because no policy exists. + // Refusing an inline `agents: { drafter: { cli, model } }` declaration in + // that state forces every self-contained example flow to ship a second file. + // A real allowlist (even an empty one from a found flows.json) still + // enforces; that state is signalled by modelRegistryPath. + const enforceRegistry = options.modelRegistryPath !== undefined; // Named declarations remain in the normalized authoring object until this // boundary so even unused or step-shadowed models are checked. toKernelSpec // erases the map and selector only after this pass has had a chance to fail. for (const [agent, declaration] of Object.entries(flow.agents ?? {})) { if (isKnownModel(declaration.model, options.models)) continue; + if (!enforceRegistry) continue; diagnostics.push({ severity: 'refusal', kind: 'model_unknown', diff --git a/packages/sdk/tests/preflight.test.ts b/packages/sdk/tests/preflight.test.ts index cafeacefb..327dce33f 100644 --- a/packages/sdk/tests/preflight.test.ts +++ b/packages/sdk/tests/preflight.test.ts @@ -447,6 +447,7 @@ describe('preflight: CLI resolution and refusal predicates', () => { }], }), { models: ['known-model'], + modelRegistryPath: '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/project/flows.json', probes: probes({ cli: () => { probeCalls += 1; @@ -482,6 +483,7 @@ describe('preflight: CLI resolution and refusal predicates', () => { const result = preflight(compiled, { models: ['known-model'], + modelRegistryPath: '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/project/flows.json', probes: probes({ cli: () => { probeCalls += 1; return { exists: true, authenticated: true, modelAvailable: true }; @@ -497,4 +499,68 @@ describe('preflight: CLI resolution and refusal predicates', () => { expect(toKernelSpec(compiled)).not.toHaveProperty('agents'); }, ); + + it('accepts an inline named-agent model when no flows.json registry is found', () => { + // #263: a self-contained flow that declares agents inline should validate + // without a mandatory external flows.json allowlist. The CLI+model probe + // still runs (below); model_unknown is a *governance* refusal about a + // registry-declared allowlist, and no registry means no policy to enforce. + let probeCalls = 0; + const result = preflight({ + version: '0.1.0', + agents: { drafter: { cli: 'claude', model: 'claude-sonnet-5' } }, + steps: [{ + id: 'draft', + type: 'agent', + agent: 'drafter', + instruction: 'Draft.', + }], + }, { + // Exactly what check.ts sends when readProjectConfig finds no flows.json: + // models is empty and modelRegistryPath is absent. + models: [], + probes: probes({ + cli: () => { + probeCalls += 1; + return { exists: true, authenticated: true, modelAvailable: true }; + }, + }), + }); + + expect(result.ok).toBe(true); + expect(result.diagnostics).toEqual([]); + expect(result.resolutions).toEqual([{ + stepId: 'draft', + cli: 'claude', + source: 'named', + model: 'claude-sonnet-5', + }]); + // The probe still ran: model authority does not skip auth verification. + expect(probeCalls).toBe(1); + }); + + it('still refuses an inline named-agent model when a registry IS present and disallows it', () => { + // Governance semantics preserved: once flows.json declares an allowlist, + // an inline model outside it is still model_unknown. The relaxation in the + // previous test is *only* for the no-registry state. + const result = preflight({ + version: '0.1.0', + agents: { drafter: { cli: 'claude', model: 'claude-sonnet-5' } }, + steps: [{ + id: 'draft', + type: 'agent', + agent: 'drafter', + instruction: 'Draft.', + }], + }, { + models: ['claude-sonnet-4'], + modelRegistryPath: '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/project/flows.json', + probes: probes(), + }); + + expect(result.ok).toBe(false); + expect(result.diagnostics).toEqual([ + expect.objectContaining({ kind: 'model_unknown', agent: 'drafter', model: 'claude-sonnet-5' }), + ]); + }); });