fix(desktop): make Cline and Cline Pass sign-out stick (#14090)

* fix(desktop): make Cline and Cline Pass sign-out stick

Signing out of the Cline provider from the Providers page removes its
providers.json entry, but the legacy import re-adds it from the classic
extension's secrets.json (cline:clineAccountId / clineApiKey) on the next
sidecar command, so the user appears signed back in. Same root cause as
the ChatGPT/Codex sign-out fix (#14040), which only cleared Codex secrets.

Signing out of Cline Pass did nothing at all: its credentials are stored
under the "cline" provider (storageProviderId), so deleting only the
cline-pass entry left the account signed in.

Generalize the legacy-secret clearing helper to a per-provider key map
and, when a provider with a different storage provider is disabled, also
remove that storage provider's entry.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* test(desktop): cover Cline Pass sign-out cascading to the shared cline entry

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
This commit is contained in:
Saoud Rizwan
2026-09-13 14:09:26 -07:00
committed by GitHub
co-authored by Saoud Rizwan
parent cfe9cadab9
commit ea7c7f11a9
5 changed files with 134 additions and 65 deletions
@@ -8,6 +8,11 @@ const getProviderSettingsMock = vi.hoisted(() => vi.fn());
const saveProviderSettingsMock = vi.hoisted(() => vi.fn());
const persistProviderSettingsMock = vi.hoisted(() => vi.fn());
const resolveProviderApiKeyMock = vi.hoisted(() => vi.fn());
const clearLegacyProviderCredentialsMock = vi.hoisted(() => vi.fn());
vi.mock("./legacy-provider-credentials", () => ({
clearLegacyProviderCredentials: clearLegacyProviderCredentialsMock,
}));
vi.mock("@cline/core", async () => {
const actual =
@@ -59,6 +64,7 @@ beforeEach(() => {
saveProviderSettingsMock.mockReset();
persistProviderSettingsMock.mockReset();
resolveProviderApiKeyMock.mockReset();
clearLegacyProviderCredentialsMock.mockReset();
});
describe("cline_account command auth states", () => {
@@ -365,6 +371,31 @@ describe("cline_account keeps feature-flag identity in sync", () => {
);
});
it("signs out of the shared cline entry and legacy secrets when cline-pass is disabled", async () => {
const { ctx } = createContext();
getProviderSettingsMock.mockReturnValue(undefined);
saveProviderSettingsMock.mockImplementation(
(_manager: unknown, request: { providerId: string }) => ({
providerId: request.providerId,
enabled: false,
settingsPath: "/tmp/settings.json",
}),
);
const { handleCommand } = await import("./commands");
await handleCommand(ctx, "save_provider_settings", {
provider: "cline-pass",
enabled: false,
});
// Cline Pass stores its credentials under "cline", so both entries go,
// and the legacy secrets are cleared for the storage provider.
expect(saveProviderSettingsMock.mock.calls.map(([, r]) => r)).toEqual([
expect.objectContaining({ providerId: "cline-pass", enabled: false }),
{ providerId: "cline", enabled: false },
]);
expect(clearLegacyProviderCredentialsMock).toHaveBeenCalledWith("cline");
});
it("ignores settings writes for other providers", async () => {
const { ctx } = createContext();
resolveProviderApiKeyMock.mockResolvedValue({ apiKey: "token" });
+20 -11
View File
@@ -24,6 +24,7 @@ import {
fetchClineRecommendedModels,
getCoreBuiltinToolCatalog,
getLocalProviderModels,
getProviderAuthHandler,
identifyAccount,
listHookConfigFiles,
listLocalProviders,
@@ -90,10 +91,7 @@ import {
identifyDesktopFeatureFlagsAccount,
refreshDesktopFeatureFlags,
} from "./feature-flags";
import {
clearLegacyCodexCredentials,
OPENAI_CODEX_PROVIDER_ID,
} from "./legacy-codex-credentials";
import { clearLegacyProviderCredentials } from "./legacy-provider-credentials";
import {
installMarketplaceEntryForDesktopCommand,
listMarketplaceInstalledEntries,
@@ -2059,6 +2057,24 @@ export async function handleCommand(
apiKey: typeof args?.api_key === "string" ? args.api_key : undefined,
baseUrl: typeof args?.base_url === "string" ? args.base_url : undefined,
});
if (!saved.enabled) {
// Cline Pass keeps its credentials under "cline", so removing only
// its own entry would leave the account signed in.
const storageProviderId =
getProviderAuthHandler(saved.providerId)?.storageProviderId ??
saved.providerId;
if (storageProviderId !== saved.providerId) {
saveLocalProviderSettings(manager, {
providerId: storageProviderId,
enabled: false,
});
}
// Removing a providers.json entry lets the legacy import restore it
// from the extension's secrets.json on the next command unless those
// credentials go too. A failed write throws so the webview reports
// the sign-out as failed and resyncs.
clearLegacyProviderCredentials(storageProviderId);
}
// Sign-out is a `save_provider_settings` that blanks the cline auth block
// (see signOut in webview settings/account-view.tsx), so this is the
// authoritative signal — it fires the moment credentials are cleared
@@ -2066,13 +2082,6 @@ export async function handleCommand(
if (saved.providerId === "cline" || saved.providerId === "cline-pass") {
syncAccountContextFromSettings(ctx, manager);
}
// Signing out of ChatGPT removes its providers.json entry; the legacy
// import would restore it from the extension's secrets.json on the next
// command unless those credentials go too. A failed write throws so
// the webview reports the sign-out as failed and resyncs.
if (saved.providerId === OPENAI_CODEX_PROVIDER_ID && !saved.enabled) {
clearLegacyCodexCredentials();
}
return saved;
}
if (command === "add_provider") {
@@ -1,47 +0,0 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { resolveClineDataDir } from "@cline/shared/storage";
export const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
const LEGACY_CODEX_SECRET_KEY = "openai-codex-oauth-credentials";
/**
* Removes the ChatGPT (Codex) OAuth credentials from the legacy VS Code
* extension's secrets.json. The legacy import in ProviderSettingsManager runs
* on construction and re-adds any provider missing from providers.json, so
* leaving these credentials on disk would sign the user straight back in
* after they sign out in the desktop app. Temporary until the legacy import
* is retired.
*
* A missing or unparseable file is a no-op (the import ignores those too).
* A failed write throws so the sign-out is reported as failed instead of
* succeeding and then being undone by the next import.
*/
export function clearLegacyCodexCredentials(
dataDir: string = resolveClineDataDir(),
): boolean {
const secretsPath = join(dataDir, "secrets.json");
if (!existsSync(secretsPath)) {
return false;
}
let secrets: unknown;
try {
secrets = JSON.parse(readFileSync(secretsPath, "utf8"));
} catch {
return false;
}
if (
!secrets ||
typeof secrets !== "object" ||
Array.isArray(secrets) ||
!(LEGACY_CODEX_SECRET_KEY in secrets)
) {
return false;
}
delete (secrets as Record<string, unknown>)[LEGACY_CODEX_SECRET_KEY];
writeFileSync(secretsPath, `${JSON.stringify(secrets, null, 2)}\n`, {
encoding: "utf8",
mode: 0o600,
});
return true;
}
@@ -2,9 +2,9 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { clearLegacyCodexCredentials } from "./legacy-codex-credentials";
import { clearLegacyProviderCredentials } from "./legacy-provider-credentials";
describe("clearLegacyCodexCredentials", () => {
describe("clearLegacyProviderCredentials", () => {
const tempDirs: string[] = [];
afterEach(() => {
@@ -28,25 +28,45 @@ describe("clearLegacyCodexCredentials", () => {
}),
);
expect(clearLegacyCodexCredentials(dataDir)).toBe(true);
expect(clearLegacyProviderCredentials("openai-codex", dataDir)).toBe(true);
expect(JSON.parse(readFileSync(secretsPath, "utf8"))).toEqual({
openRouterApiKey: "sk-or-keep",
});
});
it("is a no-op when the file is missing or has no Codex credentials", () => {
it("removes both Cline account secrets from the legacy secrets file", () => {
const dataDir = mkdtempSync(path.join(os.tmpdir(), "desktop-legacy-"));
tempDirs.push(dataDir);
expect(clearLegacyCodexCredentials(dataDir)).toBe(false);
const secretsPath = path.join(dataDir, "secrets.json");
writeFileSync(
secretsPath,
JSON.stringify({
"cline:clineAccountId": JSON.stringify({ idToken: "t" }),
clineApiKey: "cline-key",
openRouterApiKey: "sk-or-keep",
}),
);
expect(clearLegacyProviderCredentials("cline", dataDir)).toBe(true);
expect(JSON.parse(readFileSync(secretsPath, "utf8"))).toEqual({
openRouterApiKey: "sk-or-keep",
});
});
it("is a no-op when the file is missing, has no matching credentials, or the provider is unknown", () => {
const dataDir = mkdtempSync(path.join(os.tmpdir(), "desktop-legacy-"));
tempDirs.push(dataDir);
expect(clearLegacyProviderCredentials("openai-codex", dataDir)).toBe(false);
const secretsPath = path.join(dataDir, "secrets.json");
writeFileSync(secretsPath, JSON.stringify({ apiKey: "keep" }));
expect(clearLegacyCodexCredentials(dataDir)).toBe(false);
expect(clearLegacyProviderCredentials("cline", dataDir)).toBe(false);
expect(clearLegacyProviderCredentials("anthropic", dataDir)).toBe(false);
expect(readFileSync(secretsPath, "utf8")).toBe(
JSON.stringify({ apiKey: "keep" }),
);
writeFileSync(secretsPath, "{not json");
expect(clearLegacyCodexCredentials(dataDir)).toBe(false);
expect(clearLegacyProviderCredentials("openai-codex", dataDir)).toBe(false);
});
});
@@ -0,0 +1,56 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { resolveClineDataDir } from "@cline/shared/storage";
/**
* Legacy VS Code extension secrets.json keys that the legacy import in
* ProviderSettingsManager turns back into a providers.json entry.
*/
const LEGACY_SECRET_KEYS_BY_PROVIDER: Record<string, string[]> = {
"openai-codex": ["openai-codex-oauth-credentials"],
cline: ["cline:clineAccountId", "clineApiKey"],
};
/**
* Removes a provider's credentials from the legacy VS Code extension's
* secrets.json. The legacy import in ProviderSettingsManager runs on
* construction and re-adds any provider missing from providers.json, so
* leaving these credentials on disk would sign the user straight back in
* after they sign out in the desktop app. Temporary until the legacy import
* is retired.
*
* A missing or unparseable file is a no-op (the import ignores those too).
* A failed write throws so the sign-out is reported as failed instead of
* succeeding and then being undone by the next import.
*/
export function clearLegacyProviderCredentials(
providerId: string,
dataDir: string = resolveClineDataDir(),
): boolean {
const keys = LEGACY_SECRET_KEYS_BY_PROVIDER[providerId];
const secretsPath = join(dataDir, "secrets.json");
if (!keys || !existsSync(secretsPath)) {
return false;
}
let secrets: unknown;
try {
secrets = JSON.parse(readFileSync(secretsPath, "utf8"));
} catch {
return false;
}
if (!secrets || typeof secrets !== "object" || Array.isArray(secrets)) {
return false;
}
const present = keys.filter((key) => key in secrets);
if (present.length === 0) {
return false;
}
for (const key of present) {
delete (secrets as Record<string, unknown>)[key];
}
writeFileSync(secretsPath, `${JSON.stringify(secrets, null, 2)}\n`, {
encoding: "utf8",
mode: 0o600,
});
return true;
}