From 967b56a0dc13ee77563aa3addcca4ef4bcbb1082 Mon Sep 17 00:00:00 2001 From: Ara Date: Thu, 20 Aug 2026 22:13:00 -0700 Subject: [PATCH] fix(cline): stop generating proxy task ids (#10279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validado no worktree combinado do lote: typecheck:core, lint, gates de qualidade e os 13 testes unitários + 1 de integração (cline-task-id-propagation) todos verdes. Correção legítima de identidade de tarefa fabricada. CI vermelho neste PR é o base-red já rastreado em #9985. Obrigado! --- .../fixes/cline-task-id-passthrough.md | 1 + src/shared/utils/clineAuth.ts | 27 ++++---- .../cline-task-id-propagation.test.ts | 67 +++++++++++++++++++ tests/snapshots/provider/translate-path.json | 3 - .../cline-workos-auth-token-shape.test.ts | 17 ++++- 5 files changed, 98 insertions(+), 17 deletions(-) create mode 100644 changelog.d/fixes/cline-task-id-passthrough.md create mode 100644 tests/integration/cline-task-id-propagation.test.ts diff --git a/changelog.d/fixes/cline-task-id-passthrough.md b/changelog.d/fixes/cline-task-id-passthrough.md new file mode 100644 index 0000000000..a6d2ecec57 --- /dev/null +++ b/changelog.d/fixes/cline-task-id-passthrough.md @@ -0,0 +1 @@ +- **fix(cline):** Preserve client-supplied Cline task IDs and omit the header when clients provide none, preventing request-scoped proxy IDs from being reported as tasks. diff --git a/src/shared/utils/clineAuth.ts b/src/shared/utils/clineAuth.ts index b923332fa0..906477010f 100644 --- a/src/shared/utils/clineAuth.ts +++ b/src/shared/utils/clineAuth.ts @@ -8,8 +8,6 @@ * must route its headers through `buildClineHeaders()`. */ -import { randomUUID } from "node:crypto"; - import { APP_CONFIG } from "../constants/appConfig"; const APP_VERSION = APP_CONFIG.version; @@ -41,9 +39,11 @@ function getHeaderCaseInsensitive( return key ? cleanHeaderValue(headers?.[key]) : undefined; } -/** Keep an inbound Cline task id when supplied; otherwise create one per request. */ -export function resolveClineTaskId(clientHeaders?: Record | null): string { - return getHeaderCaseInsensitive(clientHeaders, "x-task-id") ?? randomUUID(); +/** Keep an inbound Cline task id when supplied; never invent task identity at the proxy layer. */ +export function resolveClineTaskId( + clientHeaders?: Record | null +): string | undefined { + return getHeaderCaseInsensitive(clientHeaders, "x-task-id"); } function resolveClineClientType(clientHeaders?: Record | null): string | undefined { @@ -53,18 +53,15 @@ function resolveClineClientType(clientHeaders?: Record | null): } /** - * Apply the required Cline billing headers with case-insensitive replacement. - * These fields are authoritative in the official client and must win over - * stored/configured header layers. + * Apply Cline billing headers with case-insensitive replacement. Task identity + * is optional and may only come from the request context; stored/configured + * header layers must not fabricate or override it. */ export function applyClineProtocolHeaders( headers: Record, context: ClineHeaderContext = {} ): Record { - const taskId = - cleanHeaderValue(context.taskId) ?? - getHeaderCaseInsensitive(headers, "x-task-id") ?? - randomUUID(); + const taskId = cleanHeaderValue(context.taskId); const clientVersion = cleanHeaderValue(context.clientVersion) ?? APP_VERSION; const existingClientType = getHeaderCaseInsensitive(headers, "x-client-type"); const clientType = @@ -82,9 +79,13 @@ export function applyClineProtocolHeaders( "X-PLATFORM": cleanHeaderValue(context.platform) ?? process.platform ?? "unknown", "X-PLATFORM-VERSION": cleanHeaderValue(context.platformVersion) ?? process.version ?? "unknown", "X-CORE-VERSION": cleanHeaderValue(context.coreVersion) ?? APP_VERSION, - "X-Task-ID": taskId, }; + for (const existing of Object.keys(headers)) { + if (existing.toLowerCase() === "x-task-id") delete headers[existing]; + } + if (taskId) required["X-Task-ID"] = taskId; + for (const [name, value] of Object.entries(required)) { for (const existing of Object.keys(headers)) { if (existing !== name && existing.toLowerCase() === name.toLowerCase()) { diff --git a/tests/integration/cline-task-id-propagation.test.ts b/tests/integration/cline-task-id-propagation.test.ts new file mode 100644 index 0000000000..f61f3ad284 --- /dev/null +++ b/tests/integration/cline-task-id-propagation.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createChatPipelineHarness } from "./_chatPipelineHarness.ts"; + +const harness = await createChatPipelineHarness("cline-task-id-propagation"); +const { buildRequest, cleanup, handleChat, seedConnection } = harness; + +function plainHeaders(headers: HeadersInit | undefined): Record { + return Object.fromEntries(new Headers(headers).entries()); +} + +function upstreamStream(text: string): Response { + return new Response( + [ + `data: ${JSON.stringify({ + id: "chatcmpl_repro", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { role: "assistant", content: text } }], + })}`, + "", + "data: [DONE]", + "", + ].join("\n"), + { status: 200, headers: { "Content-Type": "text/event-stream" } } + ); +} + +test.after(async () => { + await cleanup(); +}); + +test("full chat pipeline omits absent task ids and preserves inbound ids", async () => { + await seedConnection("clinepass", { apiKey: "sk-clinepass-repro" }); + const upstreamHeaders: Record[] = []; + + globalThis.fetch = async (_url, init = {}) => { + upstreamHeaders.push(plainHeaders(init.headers)); + return upstreamStream("ok"); + }; + + const send = async (headers: Record = {}) => { + const response = await handleChat( + buildRequest({ + headers, + body: { + model: "cp/cline-pass/glm-5.2", + stream: false, + messages: [{ role: "user", content: "task id reproduction" }], + }, + }) + ); + await response.text(); + assert.equal(response.status, 200); + }; + + await send(); + await send(); + await send({ "X-Task-ID": "client-task-123" }); + + assert.equal(upstreamHeaders.length, 3); + assert.equal(upstreamHeaders[0]["x-client-type"], "omniroute"); + assert.equal(upstreamHeaders[1]["x-client-type"], "omniroute"); + assert.ok(!("x-task-id" in upstreamHeaders[0])); + assert.ok(!("x-task-id" in upstreamHeaders[1])); + assert.equal(upstreamHeaders[2]["x-task-id"], "client-task-123"); +}); diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 2029896e39..31d2a7ccfa 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -1083,7 +1083,6 @@ "X-IS-MULTIROOT": "false", "X-PLATFORM": "", "X-PLATFORM-VERSION": "", - "X-Task-ID": "", "X-Title": "Cline" }, "nonStream": { @@ -1097,7 +1096,6 @@ "X-IS-MULTIROOT": "false", "X-PLATFORM": "", "X-PLATFORM-VERSION": "", - "X-Task-ID": "", "X-Title": "Cline" }, "oauth": { @@ -1112,7 +1110,6 @@ "X-IS-MULTIROOT": "false", "X-PLATFORM": "", "X-PLATFORM-VERSION": "", - "X-Task-ID": "", "X-Title": "Cline" } }, diff --git a/tests/unit/cline-workos-auth-token-shape.test.ts b/tests/unit/cline-workos-auth-token-shape.test.ts index ab637ac930..3982155b8f 100644 --- a/tests/unit/cline-workos-auth-token-shape.test.ts +++ b/tests/unit/cline-workos-auth-token-shape.test.ts @@ -7,6 +7,7 @@ import { buildClinepassHeaders, getClineAccessToken, getClineAuthorizationHeader, + resolveClineTaskId, } from "../../src/shared/utils/clineAuth.ts"; import { buildProviderHeaders } from "../../open-sse/services/provider.ts"; import { DefaultExecutor } from "../../open-sse/executors/default.ts"; @@ -46,13 +47,24 @@ test("buildClineHeaders emits the full cline client header set", () => { }); test("buildClineHeaders merges extra headers and omits Authorization with no token", () => { - const headers = buildClineHeaders("", { Accept: "application/json" }); + const headers = buildClineHeaders("", { + Accept: "application/json", + "x-task-id": "configured-task-must-not-leak", + }); assert.equal(headers.Accept, "application/json"); assert.ok(!("Authorization" in headers)); + assert.ok(!("X-Task-ID" in headers)); + assert.ok(!("x-task-id" in headers)); // Client-identification headers are still present even without a token. assert.equal(headers["X-CLIENT-TYPE"], "omniroute"); }); +test("resolveClineTaskId forwards client task identity but does not invent one", () => { + assert.equal(resolveClineTaskId({ "x-task-id": "client-task-123" }), "client-task-123"); + assert.equal(resolveClineTaskId({}), undefined); + assert.equal(resolveClineTaskId(null), undefined); +}); + test("required Cline protocol headers override conflicting configured casing", () => { const headers = applyClineProtocolHeaders( { @@ -109,6 +121,9 @@ test("DefaultExecutor.buildHeaders uses the cline workos auth token shape", () = assert.equal(headers["X-CLIENT-TYPE"], "omniroute"); assert.equal(headers["X-Title"], "Cline"); assert.equal(headers["X-Task-ID"], "task-from-client"); + + const withoutTaskId = executor.buildHeaders({ apiKey: "tok-abc" }, true, {}); + assert.ok(!("X-Task-ID" in withoutTaskId)); }); test("DefaultExecutor labels internal health checks separately from user traffic", () => {