Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/cline-task-id-passthrough.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 14 additions & 13 deletions src/shared/utils/clineAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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 =
Expand All @@ -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()) {
Expand Down
67 changes: 67 additions & 0 deletions tests/integration/cline-task-id-propagation.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
15 changes: 6 additions & 9 deletions tests/snapshots/provider/translate-path.json
Original file line number Diff line number Diff line change
Expand Up @@ -1083,7 +1083,6 @@
"X-IS-MULTIROOT": "false",
"X-PLATFORM": "<PLATFORM>",
"X-PLATFORM-VERSION": "<NODE>",
"X-Task-ID": "<UUID>",
"X-Title": "Cline"
},
"nonStream": {
Expand All @@ -1097,7 +1096,6 @@
"X-IS-MULTIROOT": "false",
"X-PLATFORM": "<PLATFORM>",
"X-PLATFORM-VERSION": "<NODE>",
"X-Task-ID": "<UUID>",
"X-Title": "Cline"
},
"oauth": {
Expand All @@ -1112,7 +1110,6 @@
"X-IS-MULTIROOT": "false",
"X-PLATFORM": "<PLATFORM>",
"X-PLATFORM-VERSION": "<NODE>",
"X-Task-ID": "<UUID>",
"X-Title": "Cline"
}
},
Expand Down Expand Up @@ -2141,7 +2138,7 @@
"stream": "https://api.freeaiapikey.com/v1/chat/completions"
}
},
"freeinference": {
"freebuff": {
"format": "openai",
"headers": {
"apiKey": {
Expand All @@ -2160,11 +2157,11 @@
}
},
"url": {
"nonStream": "https://freeinference.org/v1/chat/completions",
"stream": "https://freeinference.org/v1/chat/completions"
"nonStream": "https://www.codebuff.com/api/v1",
"stream": "https://www.codebuff.com/api/v1"
}
},
"freebuff": {
"freeinference": {
"format": "openai",
"headers": {
"apiKey": {
Expand All @@ -2183,8 +2180,8 @@
}
},
"url": {
"nonStream": "https://www.codebuff.com/api/v1",
"stream": "https://www.codebuff.com/api/v1"
"nonStream": "https://freeinference.org/v1/chat/completions",
"stream": "https://freeinference.org/v1/chat/completions"
}
},
"freemodel-dev": {
Expand Down
17 changes: 16 additions & 1 deletion tests/unit/cline-workos-auth-token-shape.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
{
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading