From e84f693fcc6147cbe4c23bb7d2be17322887a6f7 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:42:02 +0900 Subject: [PATCH] fix(responses): isolate Cursor combo shadow calls --- src/server/responses/core-options.ts | 2 ++ src/server/responses/request-prepare.ts | 6 ++++++ .../responses-shadow-intercept.test.ts | 20 ++++++++++++++++++- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/server/responses/core-options.ts b/src/server/responses/core-options.ts index 5334cf4e02..110f56f724 100644 --- a/src/server/responses/core-options.ts +++ b/src/server/responses/core-options.ts @@ -108,6 +108,8 @@ export interface HandleResponsesOptions { callerDirectAuth?: CallerDirectAuth | null; /** Internal recursion guard; callers outside this module must not set it. */ comboAttempt?: boolean; + /** Internal handoff: this combo was selected by shadow-call interception. */ + shadowCallIntercepted?: boolean; compactionRoutingOverride?: CompactionRoutingOverride | null; /** Internal combo handoff for one parent-validated continuation snapshot. */ comboReplaySnapshot?: { diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 867f816414..ae303d50c4 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -203,6 +203,7 @@ export async function prepareResponsesRequest( } // Compaction may send the last client-visible bare model after a combo switch. // Configured selectors take precedence; otherwise recall before combo dispatch (#3891). + let shadowCallIntercepted = false; if (!options.comboAttempt && !options.compactionRoutingOverride && body && typeof body === "object" && !Array.isArray(body)) { const rawModel = (body as { model?: unknown }).model; const rawInput = (body as { input?: unknown }).input; @@ -232,6 +233,7 @@ export async function prepareResponsesRequest( && isShadowSourceModel(rawShadowModel, shadowIntercept.sourceModels)) { const shadowComboId = resolveComboId(config, shadowIntercept.model); if (shadowComboId && Object.hasOwn(config.combos ?? {}, shadowComboId)) { + shadowCallIntercepted = true; (body as Record).model = shadowIntercept.model; // Same rule as the late intercept site: record the operator-configured prefix that // matched, never the caller's raw model string. Matching is by prefix, so the raw @@ -247,6 +249,9 @@ export async function prepareResponsesRequest( options.onRequestBodyRead?.(); return requestDispatchers.handleComboResponses(req, body, comboId, config, logCtx, { ...options, + // Concrete combo child selectors no longer match the shadow source model. Carry the + // interception decision explicitly so provider-specific helper isolation still applies. + shadowCallIntercepted, // The original request body was accepted above. Combo children are synthetic // replays and must not repeat the caller-owned timeout transition. onRequestBodyRead: undefined, @@ -369,6 +374,7 @@ export async function prepareResponsesRequest( } } if (cursorClientThreadId) parsed._cursorClientThreadId = cursorClientThreadId; + if (options.shadowCallIntercepted === true) parsed._cursorIsolateConversation = true; } catch (err) { if (isTranslatorBudgetExceededError(err)) { return formatErrorResponse(413, "request_too_large", "request translation buffer exceeded the safe limit", { diff --git a/tests/responses/responses-shadow-intercept.test.ts b/tests/responses/responses-shadow-intercept.test.ts index d5cbdd680c..ab3800136c 100644 --- a/tests/responses/responses-shadow-intercept.test.ts +++ b/tests/responses/responses-shadow-intercept.test.ts @@ -4,7 +4,7 @@ * default follows modern clients, while sourceModels keeps an escape hatch. */ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync} from "node:fs"; +import { mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleResponses, isShadowSourceModel } from "../../src/server/responses"; @@ -15,6 +15,7 @@ import type { OcxConfig } from "../../src/types"; import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { repoPath } from "../helpers/repo-root"; const originalFetch = globalThis.fetch; let releaseSpendHome: (() => void) | undefined; @@ -307,6 +308,23 @@ function chatOk(text: string): Response { } 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 parsedHandoff = prepare.slice( + prepare.indexOf("if (cursorClientThreadId) parsed._cursorClientThreadId"), + prepare.indexOf("} catch (err)", prepare.indexOf("if (cursorClientThreadId) parsed._cursorClientThreadId")), + ); + + expect(comboDispatch).toContain("shadowCallIntercepted,"); + expect(parsedHandoff).toContain( + "if (options.shadowCallIntercepted === true) parsed._cursorIsolateConversation = true;", + ); + }); + test("a helper call rewritten to a combo hops past a 429 to the second target", async () => { takeSpendHome(); const urls: string[] = [];