diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index f9a5c204b9..1466e723aa 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2630,14 +2630,23 @@ async function handleResponsesInner( && recoverySelectionAdmission?.mainProfileDraining === true, }; const recoveryNow = Date.now(); - subagentFallbackAccountPreview = (modelId, previewNow) => previewCodexAccountForRequest( + // Carry the entitlement filter through recovery too (#2509/#2623). The scope was + // already re-previewed per candidate here; the ELIGIBLE-ACCOUNT set was not, so a + // recovered assignment could select an account that is not entitled to the model + // and then fail closed at final auth — the same class of stale-selection bug as + // the quota scope, one layer over. + subagentFallbackAccountPreview = (modelId, previewNow, modelEligibleAccountIds) => previewCodexAccountForRequest( poolAffinityKey, config, previewNow, codexQuotaScopeForModel(modelId), - recoverySelectionOptions, + { ...recoverySelectionOptions, modelEligibleAccountIds }, + ); + const recoveryPreviewAccountId = subagentFallbackAccountPreview( + parsed.modelId, + recoveryNow, + subagentFallbackModelEligibleAccountIdsForModel?.(parsed.modelId), ); - const recoveryPreviewAccountId = subagentFallbackAccountPreview(parsed.modelId, recoveryNow); return applySubagentModelFallback( parsed, req.headers, @@ -2647,6 +2656,7 @@ async function handleResponsesInner( false, recoverySelectionOptions, subagentFallbackAccountPreview, + subagentFallbackModelEligibleAccountIdsForModel, ); } finally { recoverySelectionAdmission?.release(); diff --git a/tests/subagent-fallback-handle-responses.test.ts b/tests/subagent-fallback-handle-responses.test.ts index d9c2e34964..384368eae7 100644 --- a/tests/subagent-fallback-handle-responses.test.ts +++ b/tests/subagent-fallback-handle-responses.test.ts @@ -1274,6 +1274,39 @@ describe("native fallback account preview", () => { expect(bodyRequests[1]?.auth).toContain("pool-b_token"); }); + /** + * Recovery must carry the ENTITLEMENT filter too, not only the quota scope (#2509). + * + * The end-to-end case above grants the roster to both pool accounts, so it can only prove the + * SCOPE is re-previewed per candidate. The recovery path re-previewed the scope but passed no + * eligible-account set, so it could select an account with no entitlement to the recovered + * model and fail closed at final auth — the same stale-selection class as the quota scope, one + * layer over. + * + * Asserted structurally on the source, like the route-inventory contract: driving it end to end + * needs a recovered encrypted assignment AND an account-gated candidate whose entitlement + * differs per account, and the resulting fixture proved more fragile than the thing it checks. + * What this does catch is the regression that actually threatens the fix — one of the two + * preview sites silently losing the eligibility argument again. + */ + test("both fallback preview sites pass the model-eligible account set (#2509)", async () => { + const source = await Bun.file( + new URL("../src/server/responses/core.ts", import.meta.url).pathname, + ).text(); + + const previews = source.match(/subagentFallbackAccountPreview = \([^)]*\)/g) ?? []; + // Two assignment sites: the primary selection path and the encrypted-recovery path. + expect(previews).toHaveLength(2); + // Neither may drop the third parameter — that is exactly how recovery lost it. + for (const preview of previews) { + expect(preview).toContain("modelEligibleAccountIds"); + } + + // And both must actually forward it into the preview call, not merely accept it. + const forwarded = source.match(/\{ \.\.\.(previewSelectionOptions|recoverySelectionOptions), modelEligibleAccountIds \}/g) ?? []; + expect(forwarded).toHaveLength(2); + }); + test("uses healthier pool account B when active A is above threshold", async () => { const now = 1_800_000_000_000; Date.now = () => now;