From c7173f5a6ac5703055014db3a18be09a0476c78c Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Tue, 29 Sep 2026 00:29:39 +0000 Subject: [PATCH 1/6] fix(router): constrain policy fallback redirects --- src/server/responses/policy-fallback.ts | 19 +++++++++++- structure/runtime.md | 2 +- tests/routing/routing-policy-fallback.test.ts | 30 +++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/server/responses/policy-fallback.ts b/src/server/responses/policy-fallback.ts index c78809913e2..7b66895f33e 100644 --- a/src/server/responses/policy-fallback.ts +++ b/src/server/responses/policy-fallback.ts @@ -11,6 +11,7 @@ import { captureExplicitOpenAiCallerAuth } from "../../providers/openai-sidecar" import { captureCallerDirectAuth } from "../../providers/caller-authorization"; import { resolvePolicyProfileId } from "../../routing/profile"; import { parseSyntheticRowId } from "../fast-row"; +import { routeModel } from "../../router"; type CoreHandler = typeof handleResponsesCore; type CoreOptions = Parameters[3]; @@ -23,6 +24,21 @@ function candidateKey(candidate: Pick return `${candidate.provider}\u0000${candidate.model}`; } +function staysWithinPolicyAfterRedirect( + config: OcxConfig, + trace: RouteDecisionTraceV1, + candidate: RouteCandidateTrace, +): boolean { + try { + const routed = routeModel(config, `${candidate.provider}/${candidate.model}`); + return routed.routeReason !== "blocked-model-redirect" || trace.candidates.some(allowed => + allowed.eligible && allowed.provider === routed.providerName && allowed.model === routed.modelId + ); + } catch { + return false; + } +} + /** * Rank the remaining candidates from the ORIGINAL policy trace. The initial * decision stays immutable; fallback execution belongs in attempts[], not in a @@ -185,7 +201,8 @@ export async function handleResponsesWithPolicyFallback( while (!storedPool401ReplayDispatched && await shouldHopPolicyCandidate(response, req.signal)) { if (req.signal.aborted) return response; - const next = rankPolicyFallbackCandidates(initialTrace, tried)[0]; + const next = rankPolicyFallbackCandidates(initialTrace, tried) + .find(candidate => staysWithinPolicyAfterRedirect(config, initialTrace, candidate)); if (!next) return response; tried.add(candidateKey(next)); diff --git a/structure/runtime.md b/structure/runtime.md index 27cde2f54a7..a040af90e73 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -17,7 +17,7 @@ replace `route.staticPolicy` before adapter or capability decisions continue. Mo resolved before the route result is built, so their policy is already keyed by the native wire id. `src/router.ts` applies bare `blockedModelRedirects` keys after this resolution; ordinary targets substitute one upstream model id on the selected provider/account, including slash-valued targets, while only targets explicitly naming a different configured provider reroute. Qualified source keys take precedence only for those cross-provider targets. A cross-provider redirect sets `RouteResult.credentialDomainRewrite`, which Responses and Chat header scoping treat like combo/policy routes, so a caller credential for the source route is never forwarded. Live selector hints obey the [credential-scoped cache contract](catalog.md): a selection change cannot reuse the previous credential's roster to choose an alias target. Passive OAuth observation -neither refreshes credentials nor repairs their storage. Cross-provider redirects require an enabled destination with a resolved key, usable stored OAuth account, or local/keyless auth (never source-caller forward auth), and share one visited set and five-edge budget across alias, provider, combo and policy resolution; every policy redirect must end at an eligible declared candidate, including same-provider substitutions, and pinned account selectors reject provider changes. Decision traces name the final provider/model while policy candidates retain selection evidence and combos retain their route kind and redirect reason. +neither refreshes credentials nor repairs their storage. Cross-provider redirects require an enabled destination with a resolved key, usable stored OAuth account, or local/keyless auth (never source-caller forward auth), and share one visited set and five-edge budget across alias, provider, combo and policy resolution; every policy redirect must end at an eligible declared candidate, including same-provider substitutions and candidate fallback retries, and pinned account selectors reject provider changes. Decision traces name the final provider/model while policy candidates retain selection evidence and combos retain their route kind and redirect reason. Routed Meta Muse requests use the registry-owned [Muse effort and header contract](providers-and-adapters.md); `max` reaches the provider through the existing reasoning mapper. diff --git a/tests/routing/routing-policy-fallback.test.ts b/tests/routing/routing-policy-fallback.test.ts index c09d720d0d7..f2aaef36c2d 100644 --- a/tests/routing/routing-policy-fallback.test.ts +++ b/tests/routing/routing-policy-fallback.test.ts @@ -138,6 +138,36 @@ describe("policy candidate fallback", () => { ]); }); + test("does not let a redirected fallback escape the original policy", async () => { + const trace = policyTrace(); + const config = { + port: 10100, + defaultProvider: "provider-a", + blockedModelRedirects: { "provider-b/model-b": "remote/remote-model" }, + providers: { + "provider-a": { adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authMode: "local", models: ["model-a"] }, + "provider-b": { adapter: "openai-chat", baseUrl: "http://localhost:11435/v1", authMode: "local", models: ["model-b"] }, + "provider-c": { adapter: "openai-chat", baseUrl: "http://localhost:11436/v1", authMode: "local", models: ["model-c"] }, + remote: { adapter: "openai-chat", baseUrl: "https://remote.example/v1", apiKey: "remote-key", models: ["remote-model"] }, + }, + } as OcxConfig; + const seenModels: string[] = []; + const response = await handleResponsesWithPolicyFallback(request(), config, {} as RequestLogContext, {}, { + runCore: async (req, _config, context, options) => { + const body = await req.json() as { model: string }; + options.onRequestBodyParsed?.(body); + seenModels.push(body.model); + context.routeDecision = trace; + return seenModels.length === 1 + ? Response.json({ error: { type: "rate_limit_error" } }, { status: 429 }) + : Response.json({ status: "completed" }); + }, + }); + + expect(response.status).toBe(200); + expect(seenModels).toEqual(["policy/daily", "provider-c/model-c"]); + }); + test("leaves request body parsing to the core handler", async () => { const req = request(); let cloneCalls = 0; From 964f76a6905977cc7b55203eabb3bf1f550b8ed4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:42:51 +0000 Subject: [PATCH 2/6] fix(router): keep policy fallback candidates that cannot redirect Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/server/responses/policy-fallback.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/server/responses/policy-fallback.ts b/src/server/responses/policy-fallback.ts index 7b66895f33e..d055b08631a 100644 --- a/src/server/responses/policy-fallback.ts +++ b/src/server/responses/policy-fallback.ts @@ -29,13 +29,18 @@ function staysWithinPolicyAfterRedirect( trace: RouteDecisionTraceV1, candidate: RouteCandidateTrace, ): boolean { + // "blocked-model-redirect" can only arise from a configured redirect entry; without one the + // fallback hop reproduces the candidate's ordinary route. + if (Object.keys(config.blockedModelRedirects ?? {}).length === 0) return true; try { const routed = routeModel(config, `${candidate.provider}/${candidate.model}`); return routed.routeReason !== "blocked-model-redirect" || trace.candidates.some(allowed => allowed.eligible && allowed.provider === routed.providerName && allowed.model === routed.modelId ); } catch { - return false; + // A route that fails to resolve cannot redirect anywhere; the retry itself surfaces the same + // routing failure through the normal attempt path. + return true; } } From 399b4fc13da3eb814fe2d74c81bb6395a957cb4f Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:33:56 +0900 Subject: [PATCH 3/6] fix(router): keep policy fallback usable when redirect probes fail or traces truncate An eligibility probe that throws (e.g. a blocked-model redirect cycle) now skips that fallback candidate instead of selecting it and dead-ending the retry. Redirect membership uses the full policy evaluation result carried on the request context, so an eligible destination truncated out of the bounded trace can still receive a hop. Eligibility probes run through previewRouteModel so they never write combo selection state. --- src/router.ts | 9 +++ src/server/request-log.ts | 2 + src/server/responses/compact.ts | 1 + src/server/responses/core-normalize.ts | 1 + src/server/responses/policy-fallback.ts | 25 +++--- src/server/responses/request-prepare.ts | 3 + tests/routing/routing-policy-fallback.test.ts | 81 ++++++++++++++++++- 7 files changed, 109 insertions(+), 13 deletions(-) diff --git a/src/router.ts b/src/router.ts index 4a682c78e27..a0516afc361 100644 --- a/src/router.ts +++ b/src/router.ts @@ -89,6 +89,12 @@ export interface RouteResult { combo?: ComboPick; /** Bounded route-decision trace (RI-01); never contains secrets. */ routeDecision?: RouteDecisionTraceV1; + /** + * Full eligible provider/model membership from a policy evaluation, keyed + * "provider\u0000model". The trace's candidate list is bounded; fallback + * redirect checks need membership for eligible rows that were truncated out. + */ + policyEligibility?: ReadonlySet; /** * Set when a blocked-model redirect moved the request to a different * provider. Caller credentials addressed to the source route must not follow @@ -733,6 +739,9 @@ function routeModelInternal( ...routed, routeKind: "policy" as const, routeReason: routed.routeReason === "blocked-model-redirect" ? "blocked-model-redirect" : "policy-selected", + policyEligibility: new Set(evaluation.candidates + .filter(candidate => candidate.eligible) + .map(candidate => candidate.provider + "\u0000" + candidate.model)), routeDecision: { ...evaluation.trace, selected: { diff --git a/src/server/request-log.ts b/src/server/request-log.ts index f7986a07e0e..e34737ba04f 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -247,6 +247,8 @@ export interface RequestLogContext { terminalSource?: "upstream" | "synthetic"; /** Bounded route-decision trace (RI-01); never contains secrets. */ routeDecision?: RouteDecisionTraceV1; + /** Full eligible provider/model membership from a policy route; never logged. */ + policyEligibility?: ReadonlySet; /** Privacy-bounded JEV selection metadata; downstream usage is recorded on attempts[]. */ jevDecision?: PersistedJevDecisionV1; /** Opt-in shadow evidence, normalized again at the logging boundary. */ diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 4371aa8d732..374b6b32275 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -691,6 +691,7 @@ export async function handleResponsesCompact( logCtx.requestedModel = compactRequestedModel; logCtx.model = selectedModelId; logCtx.routeDecision = route.routeDecision; + logCtx.policyEligibility = route.policyEligibility; logCtx.provider = route.codexAccountNamespace ? `${route.providerName}-${route.codexAccountNamespace}` : route.providerName; diff --git a/src/server/responses/core-normalize.ts b/src/server/responses/core-normalize.ts index d26b7f51a89..7df661b6e32 100644 --- a/src/server/responses/core-normalize.ts +++ b/src/server/responses/core-normalize.ts @@ -185,6 +185,7 @@ export async function applyFinalRouteRequestNormalization(args: { logCtx.provider = route.providerName; logCtx.providerAdapter = route.provider.adapter; logCtx.routeDecision = route.routeDecision; + logCtx.policyEligibility = route.policyEligibility; if (route.routeReason === "model-alias" || route.modelId !== responseModelId && responseModelId.includes("/")) logCtx.requestedAlias = responseModelId; if (responsesUpstreamStreaming === false && route.provider.adapter === "openai-responses") { diff --git a/src/server/responses/policy-fallback.ts b/src/server/responses/policy-fallback.ts index d055b08631a..064b46caa0c 100644 --- a/src/server/responses/policy-fallback.ts +++ b/src/server/responses/policy-fallback.ts @@ -11,7 +11,7 @@ import { captureExplicitOpenAiCallerAuth } from "../../providers/openai-sidecar" import { captureCallerDirectAuth } from "../../providers/caller-authorization"; import { resolvePolicyProfileId } from "../../routing/profile"; import { parseSyntheticRowId } from "../fast-row"; -import { routeModel } from "../../router"; +import { previewRouteModel } from "../../router"; type CoreHandler = typeof handleResponsesCore; type CoreOptions = Parameters[3]; @@ -26,21 +26,21 @@ function candidateKey(candidate: Pick function staysWithinPolicyAfterRedirect( config: OcxConfig, - trace: RouteDecisionTraceV1, + policyEligibility: ReadonlySet, candidate: RouteCandidateTrace, ): boolean { // "blocked-model-redirect" can only arise from a configured redirect entry; without one the // fallback hop reproduces the candidate's ordinary route. if (Object.keys(config.blockedModelRedirects ?? {}).length === 0) return true; try { - const routed = routeModel(config, `${candidate.provider}/${candidate.model}`); - return routed.routeReason !== "blocked-model-redirect" || trace.candidates.some(allowed => - allowed.eligible && allowed.provider === routed.providerName && allowed.model === routed.modelId - ); + // previewRouteModel: eligibility probing must not write combo selection state. + const routed = previewRouteModel(config, `${candidate.provider}/${candidate.model}`); + return routed.routeReason !== "blocked-model-redirect" + || policyEligibility.has(`${routed.providerName}\u0000${routed.modelId}`); } catch { - // A route that fails to resolve cannot redirect anywhere; the retry itself surfaces the same - // routing failure through the normal attempt path. - return true; + // A route that fails to resolve (e.g. a redirect cycle) cannot succeed on retry; its terminal + // error is rarely hop-worthy, so keep the fallback alive for the next eligible candidate. + return false; } } @@ -203,11 +203,16 @@ export async function handleResponsesWithPolicyFallback( const tried = new Set([ candidateKey({ provider: initialTrace.selected.provider, model: initialTrace.selected.model }), ]); + // Redirect eligibility needs the full evaluation membership, not the bounded trace list. + const policyEligibility: ReadonlySet = logCtx.policyEligibility + ?? new Set(initialTrace.candidates + .filter(candidate => candidate.eligible) + .map(candidate => candidateKey(candidate))); while (!storedPool401ReplayDispatched && await shouldHopPolicyCandidate(response, req.signal)) { if (req.signal.aborted) return response; const next = rankPolicyFallbackCandidates(initialTrace, tried) - .find(candidate => staysWithinPolicyAfterRedirect(config, initialTrace, candidate)); + .find(candidate => staysWithinPolicyAfterRedirect(config, policyEligibility, candidate)); if (!next) return response; tried.add(candidateKey(next)); diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 60e0ed6fa38..5f921f29cd7 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -615,6 +615,7 @@ export async function prepareResponsesRequest( if (parsed._compactionRequest === true) parsed._portableCompaction = true; } logCtx.routeDecision = route.routeDecision; + logCtx.policyEligibility = route.policyEligibility; } catch (err) { if (err instanceof AdmissionModelDeniedError) return admissionModelDeniedResponse(err); if (err instanceof NoAvailableComboTargetsError) { @@ -827,6 +828,7 @@ export async function prepareResponsesRequest( ); credentialDomainWasRewritten = true; logCtx.routeDecision = route.routeDecision; + logCtx.policyEligibility = route.policyEligibility; } catch (err) { if (err instanceof AdmissionModelDeniedError) return admissionModelDeniedResponse(err); if (err instanceof NoAvailableComboTargetsError) { @@ -1038,6 +1040,7 @@ export async function prepareResponsesRequest( ); credentialDomainWasRewritten = true; logCtx.routeDecision = route.routeDecision; + logCtx.policyEligibility = route.policyEligibility; } catch (err) { if (err instanceof AdmissionModelDeniedError) return admissionModelDeniedResponse(err); if (err instanceof NoAvailableComboTargetsError) { diff --git a/tests/routing/routing-policy-fallback.test.ts b/tests/routing/routing-policy-fallback.test.ts index f2aaef36c2d..07fd8ad396d 100644 --- a/tests/routing/routing-policy-fallback.test.ts +++ b/tests/routing/routing-policy-fallback.test.ts @@ -145,9 +145,9 @@ describe("policy candidate fallback", () => { defaultProvider: "provider-a", blockedModelRedirects: { "provider-b/model-b": "remote/remote-model" }, providers: { - "provider-a": { adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authMode: "local", models: ["model-a"] }, - "provider-b": { adapter: "openai-chat", baseUrl: "http://localhost:11435/v1", authMode: "local", models: ["model-b"] }, - "provider-c": { adapter: "openai-chat", baseUrl: "http://localhost:11436/v1", authMode: "local", models: ["model-c"] }, + "provider-a": { adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authMode: "local", allowPrivateNetwork: true, models: ["model-a"] }, + "provider-b": { adapter: "openai-chat", baseUrl: "http://localhost:11435/v1", authMode: "local", allowPrivateNetwork: true, models: ["model-b"] }, + "provider-c": { adapter: "openai-chat", baseUrl: "http://localhost:11436/v1", authMode: "local", allowPrivateNetwork: true, models: ["model-c"] }, remote: { adapter: "openai-chat", baseUrl: "https://remote.example/v1", apiKey: "remote-key", models: ["remote-model"] }, }, } as OcxConfig; @@ -168,6 +168,81 @@ describe("policy candidate fallback", () => { expect(seenModels).toEqual(["policy/daily", "provider-c/model-c"]); }); + test("a fallback candidate with an unresolvable redirect is skipped instead of ending the fallback", async () => { + const trace = policyTrace(); + const config = { + port: 10100, + defaultProvider: "provider-a", + blockedModelRedirects: { + "provider-b/model-b": "remote/remote-model", + "remote/remote-model": "provider-b/model-b", + }, + providers: { + "provider-a": { adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authMode: "local", allowPrivateNetwork: true, models: ["model-a"] }, + "provider-b": { adapter: "openai-chat", baseUrl: "http://localhost:11435/v1", authMode: "local", allowPrivateNetwork: true, models: ["model-b"] }, + "provider-c": { adapter: "openai-chat", baseUrl: "http://localhost:11436/v1", authMode: "local", allowPrivateNetwork: true, models: ["model-c"] }, + remote: { adapter: "openai-chat", baseUrl: "https://remote.example/v1", apiKey: "remote-key", models: ["remote-model"] }, + }, + } as OcxConfig; + const seenModels: string[] = []; + const response = await handleResponsesWithPolicyFallback(request(), config, {} as RequestLogContext, {}, { + runCore: async (req, _config, context, options) => { + const body = await req.json() as { model: string }; + options.onRequestBodyParsed?.(body); + seenModels.push(body.model); + context.routeDecision = trace; + return seenModels.length === 1 + ? Response.json({ error: { type: "rate_limit_error" } }, { status: 429 }) + : Response.json({ status: "completed" }); + }, + }); + + // The cyclic redirect on provider-b cannot resolve, so it is skipped and the + // healthy eligible candidate still serves the request. + expect(response.status).toBe(200); + expect(seenModels).toEqual(["policy/daily", "provider-c/model-c"]); + }); + + test("a redirect into an eligible candidate truncated out of the trace still hops", async () => { + const trace = policyTrace(); + const config = { + port: 10100, + defaultProvider: "provider-a", + blockedModelRedirects: { "provider-b/model-b": "remote/remote-model" }, + providers: { + "provider-a": { adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authMode: "local", allowPrivateNetwork: true, models: ["model-a"] }, + "provider-b": { adapter: "openai-chat", baseUrl: "http://localhost:11435/v1", authMode: "local", allowPrivateNetwork: true, models: ["model-b"] }, + "provider-c": { adapter: "openai-chat", baseUrl: "http://localhost:11436/v1", authMode: "local", allowPrivateNetwork: true, models: ["model-c"] }, + remote: { adapter: "openai-chat", baseUrl: "https://remote.example/v1", apiKey: "remote-key", models: ["remote-model"] }, + }, + } as OcxConfig; + // Full evaluation membership, as stored on RequestLogContext for traces whose + // bounded candidate list dropped eligible rows. + const log: RequestLogContext = { + policyEligibility: new Set([ + "provider-a\u0000model-a", + "provider-b\u0000model-b", + "provider-c\u0000model-c", + "remote\u0000remote-model", + ]), + }; + const seenModels: string[] = []; + const response = await handleResponsesWithPolicyFallback(request(), config, log, {}, { + runCore: async (req, _config, context, options) => { + const body = await req.json() as { model: string }; + options.onRequestBodyParsed?.(body); + seenModels.push(body.model); + context.routeDecision = trace; + return seenModels.length === 1 + ? Response.json({ error: { type: "rate_limit_error" } }, { status: 429 }) + : Response.json({ status: "completed" }); + }, + }); + + expect(response.status).toBe(200); + expect(seenModels).toEqual(["policy/daily", "provider-b/model-b"]); + }); + test("leaves request body parsing to the core handler", async () => { const req = request(); let cloneCalls = 0; From 8176e729921290f8544b8a6044bf0ad8e5129dbd Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:34:46 +0000 Subject: [PATCH 4/6] test(routing): declare policy-fallback hop candidates in request-owned scope The dev merge made policy fallback authorization request-owned: the retry's resolved route must sit inside the eligibility captured from the real first route, and the candidate probe resolves each trace row concretely. Synthetic traces can no longer authorize candidates the real evaluation never admitted, so the fixtures now declare them for real: the second candidate joins the native-hop profile, and opencode-go-2 joins the provider map. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/claude-integration/claude-native-affinity.test.ts | 5 ++++- tests/providers/opencode-go-session-header.test.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/claude-integration/claude-native-affinity.test.ts b/tests/claude-integration/claude-native-affinity.test.ts index c52abc3743d..23f9b61a15c 100644 --- a/tests/claude-integration/claude-native-affinity.test.ts +++ b/tests/claude-integration/claude-native-affinity.test.ts @@ -129,7 +129,10 @@ describe("Claude final canonical native affinity after a Go preliminary pick", ( test("native failure leaves policy-hop request headers free of synthesized identity", async () => { clearHealthHistoryCacheForTests(); const cfg = config(); - cfg.routingProfiles = { "native-hop": { candidates: [{ provider: "openai", model: "gpt-5.6-luna" }] } }; + cfg.routingProfiles = { "native-hop": { candidates: [ + { provider: "openai", model: "gpt-5.6-luna" }, + { provider: "other", model: "m" }, + ] } }; const trace = { version: 1, decisionId: "native-hop", createdAt: Date.now(), requestedModel: "policy/native-hop", routeKind: "policy", profile: { id: "native-hop", revision: "1" }, requirements: [], candidates: [ diff --git a/tests/providers/opencode-go-session-header.test.ts b/tests/providers/opencode-go-session-header.test.ts index bedf4358909..125e13920e3 100644 --- a/tests/providers/opencode-go-session-header.test.ts +++ b/tests/providers/opencode-go-session-header.test.ts @@ -608,7 +608,7 @@ describe("OpenCode Go affinity across the policy fallback retry (#4172)", () => return Response.json({ id: "resp_policy_go", object: "response", status: "completed", output: [] }); }) as unknown as NonNullable[4]>["runCore"]; - const config = { providers: { "opencode-go": opencodeGo() } } as unknown as OcxConfig; + const config = { providers: { "opencode-go": opencodeGo(), "opencode-go-2": opencodeGo() } } as unknown as OcxConfig; const response = await handleResponsesWithPolicyFallback( req, config, { model: "", provider: "" } as never, {}, { runCore }, ); From 05bc47d728dd791ffe796c2356d74d266e752bae Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:23:40 +0900 Subject: [PATCH 5/6] test(responses): align source guards with policy request scope extraction --- tests/helpers/responses-core-source.ts | 1 + tests/responses/responses-shadow-intercept.test.ts | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/helpers/responses-core-source.ts b/tests/helpers/responses-core-source.ts index 50632431819..2dea4e7d4bb 100644 --- a/tests/helpers/responses-core-source.ts +++ b/tests/helpers/responses-core-source.ts @@ -30,6 +30,7 @@ export const RESPONSES_CORE_MODULES = [ "core-combo.ts", "core-combo-native.ts", "request-prepare.ts", + "policy-request-scope.ts", "skills-snapshot.ts", "shadow-target-availability.ts", "compaction-routing.ts", diff --git a/tests/responses/responses-shadow-intercept.test.ts b/tests/responses/responses-shadow-intercept.test.ts index 55e866118aa..d5f3ecc2b0c 100644 --- a/tests/responses/responses-shadow-intercept.test.ts +++ b/tests/responses/responses-shadow-intercept.test.ts @@ -405,10 +405,11 @@ describe("a combo shadow-call target enters the failover loop (#4129)", () => { test("carries helper conversation isolation into concrete combo children", () => { const prepare = readFileSync(repoPath("src/server/responses/request-prepare.ts"), "utf8"); - const comboDispatch = prepare.slice( - prepare.indexOf("const comboId = !options.comboAttempt"), - prepare.indexOf("let unreadableEncryptedAgentTask"), - ); + const comboDispatchStart = prepare.indexOf("return requestDispatchers.handleComboResponses("); + const comboDispatchEnd = prepare.indexOf("let unreadableEncryptedAgentTask", comboDispatchStart); + expect(comboDispatchStart).toBeGreaterThanOrEqual(0); + expect(comboDispatchEnd).toBeGreaterThan(comboDispatchStart); + const comboDispatch = prepare.slice(comboDispatchStart, comboDispatchEnd); const parsedHandoff = prepare.slice( prepare.indexOf("if (cursorClientThreadId) parsed._cursorClientThreadId"), prepare.indexOf("} catch (err)", prepare.indexOf("if (cursorClientThreadId) parsed._cursorClientThreadId")), From 5cb070123c54ed2c6e068dd6ac64d21afe188b45 Mon Sep 17 00:00:00 2001 From: lidge-jun Date: Thu, 1 Oct 2026 22:01:43 +0900 Subject: [PATCH 6/6] fix(responses): capture policy scope only for an accepted shadow target The shadow-intercept probe resolved its target through the request's policy capture, so a declined policy-profile target left its eligibility and policy route kind on the request's own route. Probe without the capture and capture only when interception is accepted. --- src/server/responses/request-prepare.ts | 17 +++++++++++------ structure/transports/policy-fallback.md | 2 ++ .../routing-policy-dispatch-scope.test.ts | 16 ++++++++++++++++ 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index ec29c98ace1..0d6ae3ec88b 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -510,14 +510,18 @@ export async function prepareResponsesRequest( // request rather than a destination it never asked for. const inboundSelector = parsed.modelId; const admissionScope = resolveAdmissionModelScope(config, options.admission); - const captureInboundRoutePolicy = (candidate: RouteResult): RouteResult => { + const captureInboundRoutePolicy = (candidate: RouteResult, captureRequestPolicy = true): RouteResult => { // Every route this request path produces passes through here: the direct // name, an alias, a policy or combo selection, a compaction override, a // shadow-intercept target and both subagent-fallback re-routes. Checking // the key's scope at this one point is what stops a rewrite from reaching // a destination the front door would have refused. + // A shadow-intercept target is probed before interception is decided, so it + // skips the request-owned policy capture here; the accepted target is + // captured where it becomes the request's route. A declined policy target + // must not lend its eligibility or "policy" route kind to the request. assertRouteAllowedByScope(admissionScope, inboundSelector, candidate); - capturePolicyRequestRoute(policyScope, candidate); + if (captureRequestPolicy) capturePolicyRequestRoute(policyScope, candidate); candidate.staticPolicy = captureRouteStaticPolicy( candidate.providerName, candidate.modelId, @@ -532,11 +536,12 @@ export async function prepareResponsesRequest( // no canonical OpenAI route for (#2901). Only the initial compaction route // may fall back to the configured default provider; combo attempts and the // later fallback/recovery re-routes keep the ordinary reservation. - const resolveRoute = (modelId: string) => captureInboundRoutePolicy(concreteSelection + const routeInbound = (modelId: string) => concreteSelection ? routeConcreteModel(config, modelId) : parsed._compactionRequest === true ? routeCompactionModel(config, modelId, evidenceFromBody(parsed._rawBody)) - : routeModel(config, modelId, evidenceFromBody(parsed._rawBody))); + : routeModel(config, modelId, evidenceFromBody(parsed._rawBody)); + const resolveRoute = (modelId: string) => captureInboundRoutePolicy(routeInbound(modelId)); // The phase's destination. Resolved through the admission-scoped resolver every other route // uses, and it fails closed exactly like the shadow target: falling back to the native model // would spend the quota the operator routed away from, without their choosing it. @@ -567,7 +572,7 @@ export async function prepareResponsesRequest( } catch { /* Native Codex helper calls remain OpenAI-owned without an enabled OpenAI route. */ } // A dead target fails this helper call once, before any send; it never falls through to // the native source model or to the default provider (#5618). - const target = resolveShadowCallTarget(_sci.model, resolveRoute); + const target = resolveShadowCallTarget(_sci.model, modelId => captureInboundRoutePolicy(routeInbound(modelId), false)); if ("unavailable" in target) { logCtx.shadowCallRewrittenFrom = sanitizeLogMetadataString(sourcePrefix); logCtx.errorCode = INTERCEPT_TARGET_UNAVAILABLE_CODE; @@ -591,7 +596,7 @@ export async function prepareResponsesRequest( ); // Helpers must not resume/append into the parent thread's Cursor conversation. parsed._cursorIsolateConversation = true; - shadowRoute = targetRoute; + shadowRoute = capturePolicyRequestRoute(policyScope, targetRoute); } } if (parsed._compactionRequest === true || options.compactionRoutingOverride) parsed._cursorIsolateConversation = true; diff --git a/structure/transports/policy-fallback.md b/structure/transports/policy-fallback.md index a2bb9338c07..89b8069220a 100644 --- a/structure/transports/policy-fallback.md +++ b/structure/transports/policy-fallback.md @@ -4,6 +4,8 @@ eligible provider/model membership and decision throughout Responses preparation and fallback. Subagent selection and encrypted-task recovery keep that membership; a replacement outside it is refused before dispatch, and policy fallback may continue with another originally eligible candidate. +A shadow-intercept target is probed without that capture and is captured only once interception is +accepted, so a declined policy target never scopes the request's own route. `src/server/responses/policy-fallback.ts` inspects and executes each retry as a concrete candidate, so a public combo or profile alias cannot reselect its destination. Redirects remain inside the original evaluation, including eligible destinations omitted from the bounded diagnostic trace. diff --git a/tests/routing/routing-policy-dispatch-scope.test.ts b/tests/routing/routing-policy-dispatch-scope.test.ts index 2c64d61570c..61fd25f0f2a 100644 --- a/tests/routing/routing-policy-dispatch-scope.test.ts +++ b/tests/routing/routing-policy-dispatch-scope.test.ts @@ -189,6 +189,22 @@ describe("policy scope through real request preparation", () => { expect(result.log.policyEligibility).toBeUndefined(); }); + test.each(["model", "model-mini"])("a declined policy shadow target does not scope the %s request", async selector => { + const config = configFor(["a", "b"]); + config.providers.a!.models = ["model", "model-mini"]; + // The source prefix resolves on provider a and policy/daily selects a/model, so the targets + // intersect and interception is declined. "model-mini" also sits outside the profile's + // eligibility, which the probe used to capture and then refuse with a 409. + config.shadowCallIntercept = { enabled: true, model: "policy/daily", sourceModels: ["model"] }; + const result = await execute(config, { selector, failures: 0 }); + expect(result.response.status).toBe(200); + expect(result.destinations).toEqual([`a/${selector}`]); + expect(result.routeKinds).toEqual(["explicit-provider"]); + expect(result.scopes).toEqual([undefined]); + expect(result.log.routeDecision?.routeKind).toBe("explicit-provider"); + expect(result.log.shadowCallRewrittenFrom).toBeUndefined(); + }); + test("the configured subagent ladder cannot send outside the initial evaluation", async () => { const config = configFor(["a", "b"]); config.subagentModelFallback = ["outside/model", "b/model"];