mirror of
https://github.com/cline/cline.git
synced 2026-09-14 11:29:25 +08:00
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:
co-authored by
Saoud Rizwan
parent
cfe9cadab9
commit
ea7c7f11a9
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+27
-7
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user