From f6e7cabe60461b9b55f7b7a0ceedeb9f56c72207 Mon Sep 17 00:00:00 2001 From: izzzzzi Date: Thu, 10 Sep 2026 22:48:06 +0700 Subject: [PATCH] fix(cline): stop workos:-prefixing ClinePass API keys and add clinepass token refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cline/ClinePass requests failed with HTTP 401 ("Please make sure you are using the latest version of Cline and re-authenticate your Cline account", #3230 / #2333 / #3644). `getClineAccessToken()` unconditionally prefixed every token with `workos:`, which is correct for Cline OAuth access tokens (WorkOS JWTs) but wrong for ClinePass API keys — those are opaque strings (e.g. `clp_…`) that the API accepts only verbatim, so the `workos:`-prefixed value was rejected. Only prefix tokens that look like a WorkOS JWT (`eyJ…`); API keys and other opaque tokens pass through untouched, and an existing `workos:` prefix is never doubled. Also register `clinepass` in the token-refresh handlers. ClinePass shares Cline's WorkOS auth endpoints, but without the entry expired ClinePass OAuth tokens were never rotated, so every request kept 401ing. Finally, list `apikey` first in the ClinePass `authModes` (ClinePass is meant to be used with an API key from app.cline.bot/settings/api-keys), and add an "Import from /models" button that pulls the live Cline catalog into custom models. --- open-sse/providers/registry/clinepass.js | 5 +- open-sse/services/tokenRefresh.js | 2 + open-sse/shared/clineAuth.js | 9 ++- .../dashboard/providers/[id]/page.js | 62 +++++++++++++++++++ tests/unit/cline-auth.test.js | 40 ++++++++++++ 5 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 tests/unit/cline-auth.test.js diff --git a/open-sse/providers/registry/clinepass.js b/open-sse/providers/registry/clinepass.js index 702054ac..1e7d0520 100644 --- a/open-sse/providers/registry/clinepass.js +++ b/open-sse/providers/registry/clinepass.js @@ -14,7 +14,10 @@ export default { }, }, category: "oauth", - authModes: ["oauth", "apikey"], + // ClinePass authenticates with a plain API key from app.cline.bot/settings/api-keys + // (category "apikey"). The OAuth extension flow used by Cline does not issue + // tokens that the ClinePass API consumer endpoint accepts (HTTP 401) — see #2333. + authModes: ["apikey", "oauth"], hasOAuth: true, transport: { baseUrl: "https://api.cline.bot/api/v1/chat/completions", diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js index dbf11ac2..ed1f4f38 100644 --- a/open-sse/services/tokenRefresh.js +++ b/open-sse/services/tokenRefresh.js @@ -148,6 +148,8 @@ const REFRESH_HANDLERS = { "codebuddy-intl": (c, log) => refreshCodebuddyIntlToken(c.refreshToken, log), trae: (c, log) => refreshTraeToken(c.refreshToken, c, log), cline: (c, log) => refreshClineToken(c.refreshToken, log), + // ClinePass shares Cline's WorkOS auth endpoints, so the same refresh works. + clinepass: (c, log) => refreshClineToken(c.refreshToken, log), zed: () => refreshZedToken(), windsurf: (c, log) => refreshWindsurfToken(c, log), // Kimi Code OAuth (merged into id `kimi`); legacy id still routes here diff --git a/open-sse/shared/clineAuth.js b/open-sse/shared/clineAuth.js index 1b2b7df6..541b060d 100644 --- a/open-sse/shared/clineAuth.js +++ b/open-sse/shared/clineAuth.js @@ -6,7 +6,14 @@ export function getClineAccessToken(token) { if (typeof token !== "string") return ""; const trimmed = token.trim(); if (!trimmed) return ""; - return trimmed.startsWith("workos:") ? trimmed : `workos:${trimmed}`; + if (trimmed.toLowerCase().startsWith("workos:")) return trimmed; + // Cline OAuth access tokens are WorkOS JWTs (base64url `eyJ…` header). + // ClinePass API keys (category "apikey", e.g. `clp_…`) are NOT JWTs and must + // be sent verbatim — prefixing them with `workos:` makes the Cline API reject + // the request with HTTP 401 ("Please make sure you're using the latest + // version of Cline and re-authenticate your Cline account."). + const isWorkOsJwt = /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/.test(trimmed); + return isWorkOsJwt ? `workos:${trimmed}` : trimmed; } export function getClineAuthorizationHeader(token) { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index df1c9db6..9657b7ed 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -81,6 +81,7 @@ export default function ProviderDetailPage() { const [oneByOneSummary, setOneByOneSummary] = useState(null); const stopOneByOneRef = useRef(false); const [importingQoderModels, setImportingQoderModels] = useState(false); + const [importingClineModels, setImportingClineModels] = useState(false); const { copied, copy } = useCopyToClipboard(); const AG_RISK_STORAGE_KEY = "ag_risk_confirmed"; @@ -612,6 +613,53 @@ export default function ProviderDetailPage() { setImportingQoderModels(false); } }; + // Fetch the live Cline /models catalog and add every model not yet present. + // Cline and ClinePass share the same catalog endpoint (api.cline.bot/api/v1/models). + const handleImportClineModels = async () => { + if (importingClineModels) return; + const activeConnection = connections.find((conn) => conn.isActive !== false); + if (!activeConnection) { + alert(translate("Please add an active Cline connection first")); + return; + } + setImportingClineModels(true); + try { + const res = await fetch(`/api/providers/${activeConnection.id}/models`); + const data = await res.json(); + if (!res.ok) { + alert(data.error || translate("Failed to fetch models")); + return; + } + const models = data.models || []; + if (models.length === 0) { + alert(translate("No models returned")); + return; + } + let importedCount = 0; + for (const model of models) { + const modelId = model.id || model.name; + if (!modelId) continue; + const alreadyExists = customModels.some( + (entry) => entry.providerAlias === providerStorageAlias && entry.id === modelId && (entry.kind || entry.type || "llm") === "llm" + ) || Object.values(modelAliases).includes(`${providerStorageAlias}/${modelId}`); + if (alreadyExists) { + continue; + } + await handleAddCustomModel(modelId, "llm", providerStorageAlias); + importedCount += 1; + } + if (importedCount === 0) { + alert(translate("All models already exist, no new models added")); + } else { + alert(translate("Successfully added") + ` ${importedCount} ` + translate("models")); + } + } catch (error) { + console.log("Error importing Cline models:", error); + alert(translate("Error fetching models") + ": " + error.message); + } finally { + setImportingClineModels(false); + } + }; const handleRunOneByOneTest = async () => { if (oneByOneRunning || connections.length === 0) return; @@ -1187,6 +1235,20 @@ export default function ProviderDetailPage() { )} + {/* Import Cline /models catalog button — only show for cline and clinepass providers */} + {(providerId === "cline" || providerId === "clinepass") && connections.some((conn) => conn.isActive !== false) && ( + + )} + {/* Suggested models from provider API — show only models not yet added */} {suggestedModels.length > 0 && (() => { const addedFullModels = new Set([ diff --git a/tests/unit/cline-auth.test.js b/tests/unit/cline-auth.test.js new file mode 100644 index 00000000..e1eaae20 --- /dev/null +++ b/tests/unit/cline-auth.test.js @@ -0,0 +1,40 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + getClineAccessToken, + getClineAuthorizationHeader, +} from "../../open-sse/shared/clineAuth.js"; + +test("getClineAccessToken keeps an existing workos: prefix", () => { + const token = "workos:eyJhbGciOiJSUzI1NiJ9.eyJwYXAiJ9"; + assert.equal(getClineAccessToken(token), token); + assert.equal(getClineAccessToken(` ${token} `), token); +}); + +test("getClineAccessToken prefixes a bare WorkOS JWT with workos:", () => { + const jwt = "eyJhbGciOiJSUzI1NiJ9.eyJwYXAiJ9"; + assert.equal(getClineAccessToken(jwt), `workos:${jwt}`); +}); + +test("getClineAccessToken does NOT prefix ClinePass API keys", () => { + // ClinePass API keys are opaque strings (e.g. clp_…). Sending them as + // `workos:clp_…` makes api.cline.bot respond 401. + assert.equal(getClineAccessToken("clp_1234567890abcdef"), "clp_1234567890abcdef"); + assert.equal(getClineAccessToken("sk-9r-abcdef"), "sk-9r-abcdef"); + assert.equal(getClineAccessToken(""), ""); + assert.equal(getClineAccessToken(" "), ""); + assert.equal(getClineAccessToken(undefined), ""); + assert.equal(getClineAccessToken(null), ""); +}); + +test("getClineAuthorizationHeader builds a Bearer header without double prefixing", () => { + assert.equal(getClineAuthorizationHeader("clp_abc"), "Bearer clp_abc"); + assert.equal( + getClineAuthorizationHeader("eyJpeg.eyJbG"), + "Bearer workos:eyJpeg.eyJbG" + ); + assert.equal( + getClineAuthorizationHeader("workos:eyJpeg.eyJbG"), + "Bearer workos:eyJpeg.eyJbG" + ); +}); \ No newline at end of file