refactor: Use OS keychain for standalone; migrate secrets atomically

This commit is contained in:
kvyb
2025-09-16 06:32:43 +08:00
parent cf183463b7
commit 9cdf09f3ee
3 changed files with 125 additions and 5 deletions
+10 -2
View File
@@ -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(() => {})
}
}
+15
View File
@@ -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)
}
+100 -3
View File
@@ -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 }