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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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([
|
||||
|
||||
40
tests/unit/cline-auth.test.js
Normal file
40
tests/unit/cline-auth.test.js
Normal 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"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user