mirror of
https://github.com/cline/cline.git
synced 2026-09-19 02:05:44 +08:00
refactor: Use OS keychain for standalone; migrate secrets atomically
This commit is contained in:
@@ -66,6 +66,10 @@ export class CredentialStorage extends ClineStorage {
|
||||
protected async _store(key: string, value: string): Promise<void> {
|
||||
const { service, account, target } = this.getCredentialIdentifiers(key)
|
||||
try {
|
||||
// Best-effort replace: delete first (ignore errors), then store
|
||||
try {
|
||||
await this.exec(this.commands.delete({ service, account, target }))
|
||||
} catch {}
|
||||
await this.exec(this.commands.store({ service, account, target, value }))
|
||||
} catch (error) {
|
||||
throw error
|
||||
@@ -109,13 +113,17 @@ export class CredentialStorage extends ClineStorage {
|
||||
}
|
||||
|
||||
private initWindowsCredentialManager(): void {
|
||||
// Non-blocking setup; use -NoProfile to speed up and reduce side effects
|
||||
this.exec({
|
||||
command: "powershell.exe",
|
||||
args: [
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
"if (-not (Get-Module -ListAvailable -Name CredentialManager)) { Install-Module -Name CredentialManager -Force -Scope CurrentUser }",
|
||||
"if (-not (Get-Module -ListAvailable -Name CredentialManager)) { Install-Module -Name CredentialManager -Force -Scope CurrentUser -AllowClobber -Confirm:$false }",
|
||||
],
|
||||
})
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@ import { ExternalHostBridgeClientManager } from "@hosts/external/host-bridge-cli
|
||||
import { WebviewProviderType } from "@shared/webview/types"
|
||||
import * as path from "path"
|
||||
import { initialize, tearDown } from "@/common"
|
||||
import { secretStorage } from "@/core/storage/secrets"
|
||||
import { WebviewProvider } from "@/core/webview"
|
||||
import { AuthHandler } from "@/hosts/external/AuthHandler"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { DiffViewProvider } from "@/integrations/editor/DiffViewProvider"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
import { waitForHostBridgeReady } from "./hostbridge-client"
|
||||
import { startProtobusService } from "./protobus-service"
|
||||
import { log } from "./utils"
|
||||
@@ -33,6 +35,19 @@ async function main() {
|
||||
|
||||
AuthHandler.getInstance().setEnabled(true)
|
||||
|
||||
// Mirror VS Code behavior: react to clineAccountId secret changes (login/logout)
|
||||
secretStorage.onDidChange(async ({ key }) => {
|
||||
if (key !== "clineAccountId") return
|
||||
const value = await secretStorage.get("clineAccountId")
|
||||
const controller = webviewProvider.controller
|
||||
const authService = AuthService.getInstance(controller)
|
||||
if (value) {
|
||||
authService?.restoreRefreshTokenAndRetrieveAuthInfo()
|
||||
} else {
|
||||
authService?.handleDeauth()
|
||||
}
|
||||
})
|
||||
|
||||
startProtobusService(webviewProvider.controller)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { mkdirSync, readFileSync } from "fs"
|
||||
import os from "os"
|
||||
import path, { join } from "path"
|
||||
@@ -5,6 +6,8 @@ import type { Extension, ExtensionContext } from "vscode"
|
||||
import { ExtensionKind, ExtensionMode } from "vscode"
|
||||
import { URI } from "vscode-uri"
|
||||
import { CredentialStorage } from "@/core/storage/credential"
|
||||
import { FileBasedStorage } from "@/core/storage/file"
|
||||
import { secretStorage } from "@/core/storage/secrets"
|
||||
import { log } from "./utils"
|
||||
import { EnvironmentVariableCollection, MementoStore, readJson, SecretStore } from "./vscode-context-utils"
|
||||
|
||||
@@ -14,10 +17,20 @@ log("Running standalone cline", version)
|
||||
export const CLINE_DIR = process.env.CLINE_DIR || `${os.homedir()}/.cline`
|
||||
const DATA_DIR = path.join(CLINE_DIR, "data")
|
||||
const INSTALL_DIR = process.env.INSTALL_DIR || __dirname
|
||||
const SECRETS_FILE = path.join(DATA_DIR, "secrets.json")
|
||||
|
||||
mkdirSync(DATA_DIR, { recursive: true })
|
||||
log("Using settings dir:", DATA_DIR)
|
||||
|
||||
// Initialize the unified secret storage backend for standalone
|
||||
const standaloneBackend = selectStandaloneSecrets(DATA_DIR)
|
||||
secretStorage.init(standaloneBackend)
|
||||
|
||||
// One-time migration: if using OS credentials and secrets.json exists, migrate entries
|
||||
if (standaloneBackend instanceof CredentialStorage) {
|
||||
void migrateFileSecretsToOS(SECRETS_FILE)
|
||||
}
|
||||
|
||||
const EXTENSION_DIR = path.join(INSTALL_DIR, "extension")
|
||||
const EXTENSION_MODE = process.env.IS_DEV === "true" ? ExtensionMode.Development : ExtensionMode.Production
|
||||
|
||||
@@ -38,9 +51,8 @@ const extensionContext: ExtensionContext = {
|
||||
|
||||
// Set up KV stores.
|
||||
globalState: new MementoStore(path.join(DATA_DIR, "globalState.json")),
|
||||
// Example using CredentialStorage with fallback to SecretStore
|
||||
// TODO: Use storage based on host configurations. E.g. 'credential', 'stateless', 'file', 'client', etc.
|
||||
secrets: new CredentialStorage() || new SecretStore(path.join(DATA_DIR, "secrets.json")),
|
||||
// Note: core reads/writes secrets via the singleton; context.secrets remains for compatibility
|
||||
secrets: new CredentialStorage() || new SecretStore(SECRETS_FILE),
|
||||
|
||||
// Set up URIs.
|
||||
storageUri: URI.file(DATA_DIR),
|
||||
@@ -68,6 +80,91 @@ function getPackageInfo() {
|
||||
return { version: packageJson.version, name: packageJson.name, publisher: packageJson.publisher }
|
||||
}
|
||||
|
||||
// Select the best standalone secret storage backend (OS keychain when available, else file)
|
||||
function selectStandaloneSecrets(dataDir: string) {
|
||||
try {
|
||||
if (isMacSecurityAvailable() || isLinuxSecretToolAvailable() || isWindowsPowerShellAvailable()) {
|
||||
return new CredentialStorage()
|
||||
}
|
||||
} catch (error) {
|
||||
log(`Credential backend selection error; falling back to file store: ${String(error)}`)
|
||||
}
|
||||
return new FileBasedStorage(path.join(dataDir, "secrets.json"))
|
||||
}
|
||||
|
||||
function isMacSecurityAvailable(): boolean {
|
||||
return process.platform === "darwin" && hasCommand("security")
|
||||
}
|
||||
|
||||
function isLinuxSecretToolAvailable(): boolean {
|
||||
return process.platform === "linux" && hasCommand("secret-tool")
|
||||
}
|
||||
|
||||
function isWindowsPowerShellAvailable(): boolean {
|
||||
return process.platform === "win32" // powershell is expected; CredentialStorage handles module setup
|
||||
}
|
||||
|
||||
function hasCommand(cmd: string): boolean {
|
||||
if (process.platform === "win32") return true
|
||||
const result = spawnSync("sh", ["-c", `command -v ${cmd}`], { stdio: "ignore" })
|
||||
return result.status === 0
|
||||
}
|
||||
|
||||
// Migrate legacy secrets.json to OS credential storage atomically
|
||||
async function migrateFileSecretsToOS(filePath: string): Promise<void> {
|
||||
try {
|
||||
const fs = await import("fs")
|
||||
if (!fs.existsSync(filePath)) return
|
||||
const raw = fs.readFileSync(filePath, "utf-8")
|
||||
const data = raw ? (JSON.parse(raw) as Record<string, string>) : {}
|
||||
const entries = Object.entries(data).filter(([, v]) => typeof v === "string" && v.length > 0)
|
||||
if (entries.length === 0) return fs.unlinkSync(filePath)
|
||||
|
||||
// Parallel pre-check: determine which entries already exist in OS storage
|
||||
const existingValues = await Promise.all(entries.map(([key]) => secretStorage.get(key)))
|
||||
const preexisting = new Set<string>()
|
||||
const toWrite: Array<[string, string]> = []
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const [key, value] = entries[i]
|
||||
const existing = existingValues[i]
|
||||
if (typeof existing === "string" && existing.length > 0) {
|
||||
preexisting.add(key)
|
||||
} else {
|
||||
toWrite.push([key, value])
|
||||
}
|
||||
}
|
||||
|
||||
if (toWrite.length === 0) {
|
||||
// Everything already present in OS; remove legacy file
|
||||
fs.unlinkSync(filePath)
|
||||
log("Secrets migration: all entries already present; removed secrets.json")
|
||||
return
|
||||
}
|
||||
|
||||
// Attempt to write all pending entries atomically: on any failure, roll back successful writes
|
||||
const written: string[] = []
|
||||
try {
|
||||
for (const [key, value] of toWrite) {
|
||||
await secretStorage.store(key, value)
|
||||
written.push(key)
|
||||
}
|
||||
// Success: delete legacy file entirely
|
||||
fs.unlinkSync(filePath)
|
||||
log(`Secrets migration: migrated ${written.length + preexisting.size} entries; removed secrets.json`)
|
||||
} catch (error) {
|
||||
// Roll back only entries we wrote in this attempt; keep legacy file intact
|
||||
for (const key of written) {
|
||||
try {
|
||||
await secretStorage.delete(key)
|
||||
} catch {}
|
||||
}
|
||||
log(`Secrets migration aborted and rolled back; reason: ${String(error)}`)
|
||||
}
|
||||
} catch (error) {
|
||||
log(`Migration from secrets.json failed or partial (non-fatal): ${String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Finished loading vscode context...")
|
||||
|
||||
export { extensionContext }
|
||||
|
||||
Reference in New Issue
Block a user