From 5dfd2696d1afa95b27eb27118edc61e20b5fbf33 Mon Sep 17 00:00:00 2001 From: Time Attakc <89218912+time-attack@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:17:25 -0700 Subject: [PATCH] =?UTF-8?q?fix(ai):=20Azure=20Entra=20mode=20is=20explicit?= =?UTF-8?q?=20opt-in=20only=20=E2=80=94=20no=20silent=20az=20shell-out=20o?= =?UTF-8?q?n=20missing=20key=20(#3460)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Garry Tan Co-authored-by: Claude Fable 5 --- src/core/ai/recipes/azure-openai.ts | 9 +++++++-- test/ai/recipe-azure-openai.test.ts | 6 ++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/core/ai/recipes/azure-openai.ts b/src/core/ai/recipes/azure-openai.ts index 46b810c9c..d4302b7b8 100644 --- a/src/core/ai/recipes/azure-openai.ts +++ b/src/core/ai/recipes/azure-openai.ts @@ -45,9 +45,14 @@ export function __setEntraTokenForTests(token: string | null): void { _entraToken = token === null ? null : { token, fetchedAt: Date.now() }; } -/** Entra/keyless mode: explicit opt-in, or no api-key present (disableLocalAuth). */ +/** Entra/keyless mode: EXPLICIT opt-in only (AZURE_OPENAI_USE_ENTRA=1 / + * config azure_openai_use_entra). A missing api-key must NOT silently shell + * out to `az` — that surprises CI boxes and every non-Azure-CLI environment, + * and it broke the cross-recipe auth iron-rule test. Keyless subscriptions + * (disableLocalAuth) set the flag; missing key without the flag keeps the + * original loud AIConfigError. */ function isEntraMode(env: Record): boolean { - return env.AZURE_OPENAI_USE_ENTRA === '1' || !env.AZURE_OPENAI_API_KEY; + return env.AZURE_OPENAI_USE_ENTRA === '1'; } /** diff --git a/test/ai/recipe-azure-openai.test.ts b/test/ai/recipe-azure-openai.test.ts index 6ef77cfcc..84cf5e04d 100644 --- a/test/ai/recipe-azure-openai.test.ts +++ b/test/ai/recipe-azure-openai.test.ts @@ -74,7 +74,7 @@ describe('recipe: azure-openai', () => { expect(auth.headerName).toBe('api-key'); }); - test('resolveAuth Entra mode (no key) returns Authorization Bearer from the token cache', async () => { + test('resolveAuth Entra mode (explicit opt-in, no key) returns Authorization Bearer from the token cache', async () => { const { __setEntraTokenForTests } = await import('../../src/core/ai/recipes/azure-openai.ts'); __setEntraTokenForTests('fake-aad-token'); try { @@ -82,6 +82,7 @@ describe('recipe: azure-openai', () => { const auth = r.resolveAuth!({ AZURE_OPENAI_ENDPOINT: FULL_ENV.AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_DEPLOYMENT: FULL_ENV.AZURE_OPENAI_DEPLOYMENT, + AZURE_OPENAI_USE_ENTRA: '1', }); expect(auth.headerName).toBe('Authorization'); expect(auth.token).toBe('Bearer fake-aad-token'); @@ -110,7 +111,8 @@ describe('recipe: azure-openai', () => { const cfg = r.resolveOpenAICompatConfig!({ AZURE_OPENAI_ENDPOINT: FULL_ENV.AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_DEPLOYMENT: FULL_ENV.AZURE_OPENAI_DEPLOYMENT, - }); // no key → Entra mode + AZURE_OPENAI_USE_ENTRA: '1', + }); // explicit Entra opt-in (no key) const capturedAuth: (string | null)[] = []; const realFetch = globalThis.fetch; globalThis.fetch = ((_input: any, init?: any) => {