mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 22:52:19 +03:00
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:
1
changelog.d/fixes/cline-task-id-passthrough.md
Normal file
1
changelog.d/fixes/cline-task-id-passthrough.md
Normal 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.
|
||||
@@ -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()) {
|
||||
|
||||
67
tests/integration/cline-task-id-propagation.test.ts
Normal file
67
tests/integration/cline-task-id-propagation.test.ts
Normal 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");
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user