fix(cline): stop workos:-prefixing ClinePass API keys and add clinepass token refresh

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.
This commit is contained in:
izzzzzi
2026-09-10 22:48:06 +07:00
committed by decolua
parent 45ec1d30bb
commit f6e7cabe60
5 changed files with 116 additions and 2 deletions

View File

@@ -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",

View File

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

View File

@@ -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) {

View File

@@ -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() {
</button>
)}
{/* Import Cline /models catalog button — only show for cline and clinepass providers */}
{(providerId === "cline" || providerId === "clinepass") && connections.some((conn) => conn.isActive !== false) && (
<button
onClick={handleImportClineModels}
disabled={importingClineModels}
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-blue-500/40 px-3 py-2 text-xs text-blue-600 dark:text-blue-400 transition-colors hover:border-blue-500 hover:bg-blue-500/5 sm:w-auto disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="material-symbols-outlined text-sm" style={importingClineModels ? { animation: "spin 1s linear infinite" } : undefined}>
{importingClineModels ? "progress_activity" : "download"}
</span>
{importingClineModels ? translate("Fetching...") : translate("Import from /models")}
</button>
)}
{/* Suggested models from provider API — show only models not yet added */}
{suggestedModels.length > 0 && (() => {
const addedFullModels = new Set([

View File

@@ -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"
);
});