From b9fdcfc0061719accb9981679d41900972de76fc Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:01:32 -0300 Subject: [PATCH] fix(sse): use workos auth token shape for cline (#4787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. --- open-sse/executors/default.ts | 7 ++ open-sse/services/provider.ts | 6 ++ src/shared/utils/clineAuth.ts | 62 ++++++++++++++++ .../cline-workos-auth-token-shape.test.ts | 73 +++++++++++++++++++ 4 files changed, 148 insertions(+) create mode 100644 src/shared/utils/clineAuth.ts create mode 100644 tests/unit/cline-workos-auth-token-shape.test.ts diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index f817bd0290..562f475ed1 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -33,6 +33,7 @@ import { buildMaritalkChatUrl } from "../config/maritalk.ts"; import { LOCAL_PROVIDERS } from "@/shared/constants/providers"; import { isForbiddenCustomHeaderName } from "@/shared/constants/upstreamHeaders"; import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestDefaults"; +import { buildClineHeaders } from "@/shared/utils/clineAuth"; import type { PoolConfig } from "../services/sessionPool/types.ts"; @@ -447,6 +448,12 @@ export class DefaultExecutor extends BaseExecutor { case "glm-coding-apikey": headers["x-api-key"] = effectiveKey || credentials.accessToken; break; + case "cline": + // Cline's API requires the bearer token prefixed with `workos:` plus a + // set of Cline client-identification headers; plain `Bearer ` + // is rejected upstream. buildClineHeaders() emits both. + Object.assign(headers, buildClineHeaders(effectiveKey || credentials.accessToken)); + break; default: if (isClaudeCodeCompatible(this.provider)) { const ccRequestDefaults = getClaudeCodeCompatibleRequestDefaults( diff --git a/open-sse/services/provider.ts b/open-sse/services/provider.ts index e269c85863..fddf0daae8 100644 --- a/open-sse/services/provider.ts +++ b/open-sse/services/provider.ts @@ -7,6 +7,7 @@ import { joinClaudeCodeCompatibleUrl, } from "./claudeCodeCompatible.ts"; import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestDefaults"; +import { buildClineHeaders } from "@/shared/utils/clineAuth"; const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-"; const OPENAI_COMPATIBLE_DEFAULTS = { @@ -347,6 +348,11 @@ export function buildProviderHeaders(provider, credentials, stream = true, body if (!stream) { headers["Accept"] = "application/json"; } + } else if (provider === "cline") { + // Cline's API requires the bearer token prefixed with `workos:` plus a set + // of Cline client-identification headers; plain `Bearer ` is rejected + // upstream. buildClineHeaders() emits both. + Object.assign(headers, buildClineHeaders(credentials.apiKey || credentials.accessToken)); } else if (entry) { // Registry-driven auth const authHeader = entry.authHeader || "bearer"; diff --git a/src/shared/utils/clineAuth.ts b/src/shared/utils/clineAuth.ts new file mode 100644 index 0000000000..929e61cbae --- /dev/null +++ b/src/shared/utils/clineAuth.ts @@ -0,0 +1,62 @@ +/** + * Cline (cline.bot) auth-shape helpers. + * + * Cline's API expects the bearer token to be prefixed with `workos:` (the + * upstream auth provider), and a set of Cline client-identification headers + * (HTTP-Referer / X-Title / X-CLIENT-* / X-PLATFORM*). Plain `Bearer ` + * without the `workos:` prefix is rejected upstream, so every Cline request + * must route its headers through `buildClineHeaders()`. + */ + +const APP_VERSION = process.env.npm_package_version || "0.0.0"; + +/** + * Normalize a raw Cline token into the `workos:`-prefixed access-token shape + * Cline expects. Idempotent: a token that already carries the prefix is + * returned untouched. Non-string / empty input yields an empty string. + */ +export function getClineAccessToken(token: unknown): string { + if (typeof token !== "string") return ""; + const trimmed = token.trim(); + if (!trimmed) return ""; + return trimmed.startsWith("workos:") ? trimmed : `workos:${trimmed}`; +} + +/** + * Build the full `Authorization` header value for a Cline request, or an empty + * string when no usable token is present. + */ +export function getClineAuthorizationHeader(token: unknown): string { + const accessToken = getClineAccessToken(token); + return accessToken ? `Bearer ${accessToken}` : ""; +} + +/** + * Build the complete Cline client header set, optionally merged with caller + * extras. The `Authorization` header is only added when a usable token is + * present (so callers can build probe headers without a token). + */ +export function buildClineHeaders( + token: unknown, + extraHeaders: Record = {} +): Record { + const authorization = getClineAuthorizationHeader(token); + const headers: Record = { + "HTTP-Referer": "https://cline.bot", + "X-Title": "Cline", + "User-Agent": `OmniRoute/${APP_VERSION}`, + "X-PLATFORM": process.platform || "unknown", + "X-PLATFORM-VERSION": process.version || "unknown", + "X-CLIENT-TYPE": "omniroute", + "X-CLIENT-VERSION": APP_VERSION, + "X-CORE-VERSION": APP_VERSION, + "X-IS-MULTIROOT": "false", + ...extraHeaders, + }; + + if (authorization) { + headers.Authorization = authorization; + } + + return headers; +} diff --git a/tests/unit/cline-workos-auth-token-shape.test.ts b/tests/unit/cline-workos-auth-token-shape.test.ts new file mode 100644 index 0000000000..595ff5d88d --- /dev/null +++ b/tests/unit/cline-workos-auth-token-shape.test.ts @@ -0,0 +1,73 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + buildClineHeaders, + getClineAccessToken, + getClineAuthorizationHeader, +} from "../../src/shared/utils/clineAuth.ts"; +import { buildProviderHeaders } from "../../open-sse/services/provider.ts"; +import { DefaultExecutor } from "../../open-sse/executors/default.ts"; + +test("getClineAccessToken prefixes the token with workos:", () => { + assert.equal(getClineAccessToken("abc123"), "workos:abc123"); +}); + +test("getClineAccessToken is idempotent when already prefixed", () => { + assert.equal(getClineAccessToken("workos:abc123"), "workos:abc123"); +}); + +test("getClineAccessToken trims and rejects empty / non-string input", () => { + assert.equal(getClineAccessToken(" abc123 "), "workos:abc123"); + assert.equal(getClineAccessToken(" "), ""); + assert.equal(getClineAccessToken(""), ""); + assert.equal(getClineAccessToken(undefined), ""); + assert.equal(getClineAccessToken(null), ""); + assert.equal(getClineAccessToken(42), ""); +}); + +test("getClineAuthorizationHeader builds a workos-prefixed bearer header", () => { + assert.equal(getClineAuthorizationHeader("abc123"), "Bearer workos:abc123"); + assert.equal(getClineAuthorizationHeader(""), ""); +}); + +test("buildClineHeaders emits the full cline client header set", () => { + const headers = buildClineHeaders("abc123"); + assert.equal(headers.Authorization, "Bearer workos:abc123"); + assert.equal(headers["HTTP-Referer"], "https://cline.bot"); + assert.equal(headers["X-Title"], "Cline"); + assert.equal(headers["X-CLIENT-TYPE"], "omniroute"); + assert.equal(headers["X-IS-MULTIROOT"], "false"); + // Branding must be OmniRoute, never 9Router. + assert.ok(/^OmniRoute\//.test(headers["User-Agent"])); + assert.ok(!/9router/i.test(JSON.stringify(headers))); +}); + +test("buildClineHeaders merges extra headers and omits Authorization with no token", () => { + const headers = buildClineHeaders("", { Accept: "application/json" }); + assert.equal(headers.Accept, "application/json"); + assert.ok(!("Authorization" in headers)); + // Client-identification headers are still present even without a token. + assert.equal(headers["X-CLIENT-TYPE"], "omniroute"); +}); + +test("buildProviderHeaders uses the cline workos auth token shape", () => { + const headers = buildProviderHeaders("cline", { apiKey: "tok-abc" }, true); + assert.equal(headers.Authorization, "Bearer workos:tok-abc"); + assert.equal(headers["HTTP-Referer"], "https://cline.bot"); + assert.equal(headers["X-CLIENT-TYPE"], "omniroute"); +}); + +test("buildProviderHeaders honors an accessToken for cline", () => { + const headers = buildProviderHeaders("cline", { accessToken: "acc-xyz" }, false); + assert.equal(headers.Authorization, "Bearer workos:acc-xyz"); +}); + +test("DefaultExecutor.buildHeaders uses the cline workos auth token shape", () => { + const executor = new DefaultExecutor("cline"); + const headers = executor.buildHeaders({ apiKey: "tok-abc" }, true); + assert.equal(headers.Authorization, "Bearer workos:tok-abc"); + assert.equal(headers["HTTP-Referer"], "https://cline.bot"); + assert.equal(headers["X-CLIENT-TYPE"], "omniroute"); + assert.equal(headers["X-Title"], "Cline"); +});