fix(cline): stop generating proxy task ids (#10279)

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!
This commit is contained in:
Ara
2026-08-20 22:13:00 -07:00
committed by GitHub
parent fac2a93dbf
commit 967b56a0dc
5 changed files with 98 additions and 17 deletions

View File

@@ -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.

View File

@@ -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<string, string> | 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<string, string> | null
): string | undefined {
return getHeaderCaseInsensitive(clientHeaders, "x-task-id");
}
function resolveClineClientType(clientHeaders?: Record<string, string> | null): string | undefined {
@@ -53,18 +53,15 @@ function resolveClineClientType(clientHeaders?: Record<string, string> | 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<string, string>,
context: ClineHeaderContext = {}
): Record<string, string> {
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()) {

View File

@@ -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<string, string> {
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<string, string>[] = [];
globalThis.fetch = async (_url, init = {}) => {
upstreamHeaders.push(plainHeaders(init.headers));
return upstreamStream("ok");
};
const send = async (headers: Record<string, string> = {}) => {
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");
});

View File

@@ -1083,7 +1083,6 @@
"X-IS-MULTIROOT": "false",
"X-PLATFORM": "<PLATFORM>",
"X-PLATFORM-VERSION": "<NODE>",
"X-Task-ID": "<UUID>",
"X-Title": "Cline"
},
"nonStream": {
@@ -1097,7 +1096,6 @@
"X-IS-MULTIROOT": "false",
"X-PLATFORM": "<PLATFORM>",
"X-PLATFORM-VERSION": "<NODE>",
"X-Task-ID": "<UUID>",
"X-Title": "Cline"
},
"oauth": {
@@ -1112,7 +1110,6 @@
"X-IS-MULTIROOT": "false",
"X-PLATFORM": "<PLATFORM>",
"X-PLATFORM-VERSION": "<NODE>",
"X-Task-ID": "<UUID>",
"X-Title": "Cline"
}
},

View File

@@ -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", () => {