mirror of
https://github.com/cline/cline.git
synced 2026-09-11 05:47:07 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7741e3df0e | ||
|
|
cf183463b7 | ||
|
|
5dc1163a56 | ||
|
|
44d142b21c | ||
|
|
3d9b45ba49 | ||
|
|
b2874bdfaf |
@@ -0,0 +1,116 @@
|
||||
import { Disposable } from "vscode"
|
||||
import { Logger } from "@/services/logging/Logger"
|
||||
import { StorageEventListener } from "./utils/types"
|
||||
|
||||
/**
|
||||
* An abstract storage class that provides a template for storage operations.
|
||||
* Subclasses must implement the protected abstract methods to define their storage logic.
|
||||
* The public methods (get, store, delete) are final and cannot be overridden.
|
||||
*/
|
||||
export abstract class ClineStorage {
|
||||
/**
|
||||
* The name of the storage, used for logging purposes.
|
||||
*/
|
||||
protected name = "ClineStorage"
|
||||
/**
|
||||
* List of subscribers to storage change events.
|
||||
*/
|
||||
private readonly subscribers: Array<StorageEventListener> = []
|
||||
|
||||
/**
|
||||
* Subscribe to storage change events.
|
||||
*/
|
||||
public onDidChange(callback: StorageEventListener): Disposable {
|
||||
this.subscribers.push(callback)
|
||||
return new Disposable(() => {
|
||||
const callbackIndex = this.subscribers.indexOf(callback)
|
||||
this.subscribers.splice(callbackIndex, 1)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire storage change event to all subscribers.
|
||||
*/
|
||||
protected async fire(key: string): Promise<void> {
|
||||
Logger.info(`[${this.name}] onDidChange event fired for '${key}'`)
|
||||
await Promise.all(this.subscribers.map((subscriber) => subscriber({ key })))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from storage. This method is final and cannot be overridden.
|
||||
* Subclasses should implement _get() to define their storage retrieval logic.
|
||||
*/
|
||||
public async get(key: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await this._get(key)
|
||||
} catch (error) {
|
||||
Logger.error(`[${this.name}] failed to get '${key}':`, error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a value in storage. This method is final and cannot be overridden.
|
||||
* Subclasses should implement _store() to define their storage logic.
|
||||
* This method automatically fires change events after storing.
|
||||
*/
|
||||
public async store(key: string, value: string): Promise<void> {
|
||||
try {
|
||||
await this._store(key, value)
|
||||
await this.fire(key)
|
||||
} catch (error) {
|
||||
Logger.error(`[${this.name}] failed to store '${key}':`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a value from storage. This method is final and cannot be overridden.
|
||||
* Subclasses should implement _delete() to define their deletion logic.
|
||||
* This method automatically fires change events after deletion.
|
||||
*/
|
||||
public async delete(key: string): Promise<void> {
|
||||
try {
|
||||
await this._delete(key)
|
||||
await this.fire(key)
|
||||
} catch (error) {
|
||||
Logger.error(`[${this.name}] failed to delete '${key}':`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract method that subclasses must implement to retrieve values from their storage.
|
||||
*/
|
||||
protected abstract _get(key: string): Promise<string | undefined>
|
||||
|
||||
/**
|
||||
* Abstract method that subclasses must implement to store values in their storage.
|
||||
*/
|
||||
protected abstract _store(key: string, value: string): Promise<void>
|
||||
|
||||
/**
|
||||
* Abstract method that subclasses must implement to delete values from their storage.
|
||||
*/
|
||||
protected abstract _delete(key: string): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple in-memory implementation of ClineStorage using a Map.
|
||||
*/
|
||||
export class InMemoryClineStorage extends ClineStorage {
|
||||
/**
|
||||
* A simple in-memory cache to store key-value pairs.
|
||||
*/
|
||||
private readonly _cache = new Map<string, string>()
|
||||
|
||||
protected async _get(key: string): Promise<string | undefined> {
|
||||
return this._cache.get(key)
|
||||
}
|
||||
|
||||
protected async _store(key: string, value: string): Promise<void> {
|
||||
this._cache.set(key, value)
|
||||
}
|
||||
|
||||
protected async _delete(key: string): Promise<void> {
|
||||
this._cache.delete(key)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import chokidar, { FSWatcher } from "chokidar"
|
||||
import type { ExtensionContext } from "vscode"
|
||||
import { getTaskHistoryStateFilePath, readTaskHistoryFromState, writeTaskHistoryToState } from "./disk"
|
||||
import { STATE_MANAGER_NOT_INITIALIZED } from "./error-messages"
|
||||
import { secretStorage } from "./secrets"
|
||||
import { GlobalState, GlobalStateKey, LocalState, LocalStateKey, SecretKey, Secrets } from "./state-keys"
|
||||
import { readGlobalStateFromDisk, readSecretsFromDisk, readWorkspaceStateFromDisk } from "./utils/state-helpers"
|
||||
|
||||
@@ -46,8 +47,8 @@ export class StateManager {
|
||||
try {
|
||||
// Load all extension state from disk
|
||||
const globalState = await readGlobalStateFromDisk(this.context)
|
||||
const secrets = await readSecretsFromDisk(this.context)
|
||||
const workspaceState = await readWorkspaceStateFromDisk(this.context)
|
||||
const secrets = await readSecretsFromDisk(secretStorage)
|
||||
|
||||
// Populate the cache with all extension state and secrets fields
|
||||
// Use populate method to avoid triggering persistence during initialization
|
||||
@@ -126,6 +127,12 @@ export class StateManager {
|
||||
|
||||
// Update cache immediately for all keys
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
// Skip unchanged values as we don't want to trigger unnecessary
|
||||
// writes & incorrectly fire an onDidChange events.
|
||||
const current = this.secretsCache[key as keyof Secrets]
|
||||
if (current === value) {
|
||||
return
|
||||
}
|
||||
this.secretsCache[key as keyof Secrets] = value
|
||||
this.pendingSecrets.add(key as SecretKey)
|
||||
})
|
||||
@@ -653,9 +660,9 @@ export class StateManager {
|
||||
Array.from(keys).map((key) => {
|
||||
const value = this.secretsCache[key]
|
||||
if (value) {
|
||||
return this.context.secrets.store(key, value)
|
||||
return secretStorage.store(key, value)
|
||||
} else {
|
||||
return this.context.secrets.delete(key)
|
||||
return secretStorage.delete(key)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { spawnSync } from "node:child_process"
|
||||
import * as os from "node:os"
|
||||
import { expect } from "chai"
|
||||
import * as sinon from "sinon"
|
||||
import { ErrorService } from "@/services/error"
|
||||
import { Logger } from "@/services/logging/Logger"
|
||||
import { CredentialStorage } from "./credential"
|
||||
|
||||
const platform = os.platform()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Check if required OS credential tools are available
|
||||
const shouldSkip = (platform === "darwin" && !hasCommand("security")) || (platform === "linux" && !hasCommand("secret-tool"))
|
||||
|
||||
describe("CredentialStorage", () => {
|
||||
if (shouldSkip) {
|
||||
console.warn("Skipping CredentialStorage tests: required OS credential tool not available")
|
||||
return
|
||||
}
|
||||
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let store: CredentialStorage
|
||||
let testKey: string
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = sinon.createSandbox()
|
||||
// Mock Logger methods to avoid HostProvider dependency
|
||||
sandbox.stub(Logger, "info").returns()
|
||||
sandbox.stub(Logger, "error").returns()
|
||||
// Mock ErrorService to avoid telemetry dependency
|
||||
const mockErrorService = {
|
||||
logMessage: sandbox.stub(),
|
||||
logException: sandbox.stub(),
|
||||
toClineError: sandbox.stub(),
|
||||
isEnabled: sandbox.stub().returns(false),
|
||||
getSettings: sandbox.stub().returns({ enabled: false, hostEnabled: false }),
|
||||
getProvider: sandbox.stub(),
|
||||
dispose: sandbox.stub().resolves(),
|
||||
}
|
||||
sandbox.stub(ErrorService, "initialize").resolves(mockErrorService as any)
|
||||
sandbox.stub(ErrorService, "get").returns(mockErrorService as any)
|
||||
await ErrorService.initialize()
|
||||
store = new CredentialStorage()
|
||||
// Generate unique key for each test to avoid conflicts
|
||||
testKey = `e2e_secret_${Date.now()}_${Math.random().toString(36).substring(7)}`
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up any test credentials
|
||||
try {
|
||||
await store.delete(testKey)
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
describe("Basic operations", () => {
|
||||
it("should store and retrieve a credential", async () => {
|
||||
const value = "test-secret"
|
||||
|
||||
// Store the credential
|
||||
await store.store(testKey, value)
|
||||
|
||||
// Retrieve and verify
|
||||
const fetched = await store.get(testKey)
|
||||
expect(fetched).to.equal(value)
|
||||
})
|
||||
|
||||
it("should delete a credential", async () => {
|
||||
const value = "test-secret-to-delete"
|
||||
|
||||
// Store the credential
|
||||
await store.store(testKey, value)
|
||||
|
||||
// Verify it exists
|
||||
const beforeDelete = await store.get(testKey)
|
||||
expect(beforeDelete).to.equal(value)
|
||||
|
||||
// Delete the credential
|
||||
await store.delete(testKey)
|
||||
|
||||
// Verify it's deleted
|
||||
const afterDelete = await store.get(testKey)
|
||||
expect(afterDelete).to.be.undefined
|
||||
})
|
||||
|
||||
it("should return undefined for non-existent keys", async () => {
|
||||
const nonExistentKey = `non_existent_${Date.now()}`
|
||||
|
||||
const result = await store.get(nonExistentKey)
|
||||
expect(result).to.be.undefined
|
||||
})
|
||||
|
||||
it("should handle updating an existing credential", async () => {
|
||||
const initialValue = "initial-secret"
|
||||
const updatedValue = "updated-secret"
|
||||
|
||||
// Store initial value
|
||||
await store.store(testKey, initialValue)
|
||||
|
||||
// Verify initial value
|
||||
const initial = await store.get(testKey)
|
||||
expect(initial).to.equal(initialValue)
|
||||
|
||||
// Delete the existing credential first (required on some platforms)
|
||||
await store.delete(testKey)
|
||||
|
||||
// Store new value
|
||||
await store.store(testKey, updatedValue)
|
||||
|
||||
// Verify updated value
|
||||
const updated = await store.get(testKey)
|
||||
expect(updated).to.equal(updatedValue)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("should handle empty string values", async () => {
|
||||
const emptyValue = ""
|
||||
|
||||
await store.store(testKey, emptyValue)
|
||||
const fetched = await store.get(testKey)
|
||||
|
||||
// Note: Some credential stores might treat empty strings differently
|
||||
// This test documents the actual behavior
|
||||
expect(fetched).to.satisfy((val: string | undefined) => val === emptyValue || val === undefined)
|
||||
})
|
||||
|
||||
it("should handle special characters in values", async () => {
|
||||
const specialValue = "test!@#$%^&*()_+-=[]{}|;':\",./<>?"
|
||||
|
||||
await store.store(testKey, specialValue)
|
||||
const fetched = await store.get(testKey)
|
||||
expect(fetched).to.equal(specialValue)
|
||||
})
|
||||
|
||||
it("should handle long values", async () => {
|
||||
const longValue = "a".repeat(1000)
|
||||
|
||||
await store.store(testKey, longValue)
|
||||
const fetched = await store.get(testKey)
|
||||
expect(fetched).to.equal(longValue)
|
||||
})
|
||||
|
||||
it("should handle special characters in keys", async () => {
|
||||
const specialKey = `test_key_with-special.chars_${Date.now()}`
|
||||
const value = "test-value"
|
||||
|
||||
try {
|
||||
await store.store(specialKey, value)
|
||||
const fetched = await store.get(specialKey)
|
||||
expect(fetched).to.equal(value)
|
||||
} finally {
|
||||
// Clean up
|
||||
try {
|
||||
await store.delete(specialKey)
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("Error handling", () => {
|
||||
it("should handle delete of non-existent key gracefully", async () => {
|
||||
const nonExistentKey = `non_existent_delete_${Date.now()}`
|
||||
|
||||
// Should not throw an error
|
||||
try {
|
||||
await store.delete(nonExistentKey)
|
||||
// If we reach here, the operation succeeded without throwing
|
||||
expect(true).to.be.true
|
||||
} catch (error) {
|
||||
// If an error is thrown, fail the test
|
||||
expect.fail(`Expected delete to not throw, but got: ${error}`)
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle concurrent operations", async () => {
|
||||
const value1 = "value1"
|
||||
const value2 = "value2"
|
||||
const key1 = `${testKey}_1`
|
||||
const key2 = `${testKey}_2`
|
||||
|
||||
try {
|
||||
// Perform multiple operations concurrently
|
||||
await Promise.all([store.store(key1, value1), store.store(key2, value2), store.get(key1), store.get(key2)])
|
||||
|
||||
// Verify stored values
|
||||
const fetched1 = await store.get(key1)
|
||||
const fetched2 = await store.get(key2)
|
||||
|
||||
expect(fetched1).to.equal(value1)
|
||||
expect(fetched2).to.equal(value2)
|
||||
} finally {
|
||||
// Clean up
|
||||
await Promise.all([store.delete(key1).catch(() => {}), store.delete(key2).catch(() => {})])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("Platform-specific behavior", () => {
|
||||
it(`should work correctly on ${platform}`, async () => {
|
||||
const platformSpecificValue = `${platform}-specific-value`
|
||||
|
||||
await store.store(testKey, platformSpecificValue)
|
||||
const fetched = await store.get(testKey)
|
||||
expect(fetched).to.equal(platformSpecificValue)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
import { spawn } from "node:child_process"
|
||||
import { getPlatformOS, PLATFORM_OS } from "@/utils/platform"
|
||||
import { ClineStorage } from "./ClineStorage"
|
||||
|
||||
type CommandSpec = { command: string; args: string[]; stdin?: string }
|
||||
|
||||
interface CommandArgs {
|
||||
service: string
|
||||
account: string
|
||||
target?: string
|
||||
}
|
||||
|
||||
interface CommandStoreArgs extends CommandArgs {
|
||||
value: string
|
||||
}
|
||||
|
||||
interface PlatformCommand {
|
||||
get: (options: CommandArgs) => CommandSpec
|
||||
store: (options: CommandStoreArgs) => CommandSpec
|
||||
delete: (options: CommandArgs) => CommandSpec
|
||||
}
|
||||
|
||||
interface PlatformCommands {
|
||||
[PLATFORM_OS.Win32]: PlatformCommand
|
||||
[PLATFORM_OS.Linux]: PlatformCommand
|
||||
[PLATFORM_OS.MacOS]: PlatformCommand
|
||||
}
|
||||
|
||||
export class CredentialStorage extends ClineStorage {
|
||||
override name = "CredentialStorage"
|
||||
private readonly commands: PlatformCommand
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
const platform = getPlatformOS()
|
||||
const commands = PLATFORM_COMMANDS[platform]
|
||||
|
||||
if (!commands) {
|
||||
throw new Error(`Unsupported platform: ${platform}`)
|
||||
}
|
||||
|
||||
if (platform === PLATFORM_OS.Win32) {
|
||||
this.initWindowsCredentialManager()
|
||||
}
|
||||
|
||||
this.commands = commands
|
||||
}
|
||||
|
||||
private getCredentialIdentifiers(key: string) {
|
||||
const service = `Cline: ${key}`
|
||||
const account = `cline_${key}`
|
||||
const target = `${service}:${account}`.replaceAll('"', "_")
|
||||
return { service, account, target }
|
||||
}
|
||||
|
||||
protected async _get(key: string): Promise<string | undefined> {
|
||||
const { service, account, target } = this.getCredentialIdentifiers(key)
|
||||
try {
|
||||
const result = await this.exec(this.commands.get({ service, account, target }))
|
||||
return result || undefined
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
protected async _store(key: string, value: string): Promise<void> {
|
||||
const { service, account, target } = this.getCredentialIdentifiers(key)
|
||||
try {
|
||||
await this.exec(this.commands.store({ service, account, target, value }))
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
protected async _delete(key: string): Promise<void> {
|
||||
const { service, account, target } = this.getCredentialIdentifiers(key)
|
||||
try {
|
||||
await this.exec(this.commands.delete({ service, account, target }))
|
||||
} catch {
|
||||
// Ignore deletion errors (key might not exist)
|
||||
}
|
||||
}
|
||||
|
||||
private exec({ command, args, stdin }: CommandSpec): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, { stdio: "pipe" })
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
|
||||
child.stdout.on("data", (data) => {
|
||||
stdout += data
|
||||
})
|
||||
|
||||
child.stderr.on("data", (data) => {
|
||||
stderr += data
|
||||
})
|
||||
|
||||
if (stdin !== undefined) {
|
||||
child.stdin.write(stdin)
|
||||
child.stdin.end()
|
||||
}
|
||||
|
||||
child.once("close", (code) => {
|
||||
code === 0 ? resolve(stdout.trim()) : reject(new Error(`${command} failed: ${stderr || stdout}`))
|
||||
})
|
||||
|
||||
child.once("error", reject)
|
||||
})
|
||||
}
|
||||
|
||||
private initWindowsCredentialManager(): void {
|
||||
this.exec({
|
||||
command: "powershell.exe",
|
||||
args: [
|
||||
"-Command",
|
||||
"if (-not (Get-Module -ListAvailable -Name CredentialManager)) { Install-Module -Name CredentialManager -Force -Scope CurrentUser }",
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const PLATFORM_COMMANDS: PlatformCommands = {
|
||||
[PLATFORM_OS.MacOS]: {
|
||||
get: ({ service, account }) => ({
|
||||
command: "security",
|
||||
args: ["find-generic-password", "-s", service, "-a", account, "-w"],
|
||||
}),
|
||||
store: ({ service, account, value }) => ({
|
||||
command: "security",
|
||||
args: ["add-generic-password", "-s", service, "-a", account, "-w", value],
|
||||
}),
|
||||
delete: ({ service, account }) => ({
|
||||
command: "security",
|
||||
args: ["delete-generic-password", "-s", service, "-a", account],
|
||||
}),
|
||||
},
|
||||
[PLATFORM_OS.Linux]: {
|
||||
get: ({ service, account }) => ({
|
||||
command: "secret-tool",
|
||||
args: ["lookup", "service", service, "account", account],
|
||||
}),
|
||||
store: ({ service, account, value }) => ({
|
||||
command: "secret-tool",
|
||||
args: ["store", "--label", service, "service", service, "account", account],
|
||||
stdin: value,
|
||||
}),
|
||||
delete: ({ service, account }) => ({
|
||||
command: "secret-tool",
|
||||
args: ["clear", "service", service, "account", account],
|
||||
}),
|
||||
},
|
||||
[PLATFORM_OS.Win32]: {
|
||||
get: ({ target }) => ({
|
||||
command: "powershell.exe",
|
||||
args: [
|
||||
"-Command",
|
||||
"param($Target); $cred = Get-StoredCredential -Target $Target; if ($cred) { $cred.GetNetworkCredential().Password } else { '' }",
|
||||
"-Target",
|
||||
target || "",
|
||||
],
|
||||
}),
|
||||
store: ({ target, value }) => ({
|
||||
command: "powershell.exe",
|
||||
args: [
|
||||
"-Command",
|
||||
"param($Target, $Value); $pass = ConvertTo-SecureString $Value -AsPlainText -Force; New-StoredCredential -Target $Target -UserName 'Cline' -SecurePassword $pass -Persist LocalMachine",
|
||||
"-Target",
|
||||
target || "",
|
||||
"-Value",
|
||||
value,
|
||||
],
|
||||
}),
|
||||
delete: ({ target }) => ({
|
||||
command: "powershell.exe",
|
||||
args: [
|
||||
"-Command",
|
||||
"param($Target); $cred = Get-StoredCredential -Target $Target; if ($cred) { Remove-StoredCredential -Target $Target }",
|
||||
"-Target",
|
||||
target || "",
|
||||
],
|
||||
}),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { ClineStorage } from "./ClineStorage"
|
||||
|
||||
/**
|
||||
* A storage implementation that uses the filesystem to store key-value pairs.
|
||||
*/
|
||||
export class FileBasedStorage extends ClineStorage {
|
||||
override name = "FileBasedStorage"
|
||||
|
||||
private readonly cache = new Map<string, string>()
|
||||
|
||||
constructor(private fsPath: string) {
|
||||
super()
|
||||
this.read()
|
||||
}
|
||||
|
||||
override async _get(key: string): Promise<string | undefined> {
|
||||
try {
|
||||
await this.read()
|
||||
return this.cache.get(key) || undefined
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
override async _store(key: string, value: string): Promise<void> {
|
||||
try {
|
||||
await this.read()
|
||||
this.cache.set(key, value)
|
||||
await this.write()
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
override async _delete(key: string): Promise<void> {
|
||||
try {
|
||||
await this.read()
|
||||
this.cache.delete(key)
|
||||
await this.write()
|
||||
} catch (error) {
|
||||
console.error("FileBasedStorage", error)
|
||||
}
|
||||
}
|
||||
|
||||
private async read(): Promise<void> {
|
||||
try {
|
||||
const fileContent = await fs.promises.readFile(this.fsPath, "utf-8")
|
||||
const json = JSON.parse(fileContent) as Record<string, string>
|
||||
this.cache.clear() // Clear existing cache
|
||||
for (const [key, value] of Object.entries(json)) {
|
||||
if (key && value) {
|
||||
this.cache.set(key, value)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async write(): Promise<void> {
|
||||
try {
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(this.fsPath)
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
|
||||
// Convert map to object and save
|
||||
const json = Object.fromEntries(this.cache)
|
||||
await fs.promises.writeFile(this.fsPath, JSON.stringify(json, null, 2), "utf-8")
|
||||
} catch (error) {
|
||||
console.error("FileBasedStorage", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { SecretStorage as VSCodeSecretStorage } from "vscode"
|
||||
import { Logger } from "@/services/logging/Logger"
|
||||
import { ClineStorage } from "./ClineStorage"
|
||||
|
||||
type SecretStores = VSCodeSecretStorage | ClineStorage
|
||||
|
||||
/**
|
||||
* Wrapper around VSCode Secret Storage or any other storage type for managing secrets.
|
||||
*/
|
||||
export class ClineSecretStorage extends ClineStorage {
|
||||
override readonly name = "ClineSecretStorage"
|
||||
private static readonly store = new ClineSecretStorage()
|
||||
static get instance(): ClineSecretStorage {
|
||||
return ClineSecretStorage.store
|
||||
}
|
||||
|
||||
private secretStorage: SecretStores | null = null
|
||||
|
||||
public get storage(): SecretStores {
|
||||
if (!this.secretStorage) {
|
||||
throw new Error("[ClineSecretStorage] init not called")
|
||||
}
|
||||
return this.secretStorage
|
||||
}
|
||||
|
||||
public init(store: SecretStores) {
|
||||
if (!this.secretStorage) {
|
||||
this.secretStorage = store
|
||||
Logger.info("[ClineSecretStorage] initialized")
|
||||
}
|
||||
return this.secretStorage
|
||||
}
|
||||
|
||||
protected async _get(key: string): Promise<string | undefined> {
|
||||
try {
|
||||
return key ? await this.storage.get(key) : undefined
|
||||
} catch (error) {
|
||||
Logger.error("[ClineSecretStorage]", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [SECURITY] Avoid logging secrets values.
|
||||
*/
|
||||
protected async _store(key: string, value: string): Promise<void> {
|
||||
try {
|
||||
if (value && value.length > 0) {
|
||||
await this.storage.store(key, value)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("[ClineSecretStorage]", error)
|
||||
}
|
||||
}
|
||||
|
||||
protected async _delete(key: string): Promise<void> {
|
||||
Logger.info("[ClineSecretStorage] deleting " + key)
|
||||
await this.storage.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance of ClineSecretStorage
|
||||
*/
|
||||
export const secretStorage = ClineSecretStorage.instance
|
||||
@@ -9,10 +9,11 @@ import { DEFAULT_MCP_DISPLAY_MODE, McpDisplayMode } from "@/shared/McpDisplayMod
|
||||
import { Mode, OpenaiReasoningEffort } from "@/shared/storage/types"
|
||||
import { TelemetrySetting } from "@/shared/TelemetrySetting"
|
||||
import { UserInfo } from "@/shared/UserInfo"
|
||||
import { ClineStorage } from "../ClineStorage"
|
||||
import { readTaskHistoryFromState } from "../disk"
|
||||
import { GlobalState, LocalState, SecretKey, Secrets } from "../state-keys"
|
||||
|
||||
export async function readSecretsFromDisk(context: ExtensionContext): Promise<Secrets> {
|
||||
export async function readSecretsFromDisk(store: ClineStorage): Promise<Secrets> {
|
||||
const [
|
||||
apiKey,
|
||||
openRouterApiKey,
|
||||
@@ -50,41 +51,41 @@ export async function readSecretsFromDisk(context: ExtensionContext): Promise<Se
|
||||
difyApiKey,
|
||||
authNonce,
|
||||
] = await Promise.all([
|
||||
context.secrets.get("apiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("openRouterApiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("clineAccountId") as Promise<string | undefined>,
|
||||
context.secrets.get("awsAccessKey") as Promise<string | undefined>,
|
||||
context.secrets.get("awsSecretKey") as Promise<string | undefined>,
|
||||
context.secrets.get("awsSessionToken") as Promise<string | undefined>,
|
||||
context.secrets.get("awsBedrockApiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("openAiApiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("geminiApiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("openAiNativeApiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("deepSeekApiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("requestyApiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("togetherApiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("qwenApiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("doubaoApiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("mistralApiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("fireworksApiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("liteLlmApiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("asksageApiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("xaiApiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("sambanovaApiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("cerebrasApiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("groqApiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("moonshotApiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("nebiusApiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("huggingFaceApiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("sapAiCoreClientId") as Promise<string | undefined>,
|
||||
context.secrets.get("sapAiCoreClientSecret") as Promise<string | undefined>,
|
||||
context.secrets.get("huaweiCloudMaasApiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("basetenApiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("zaiApiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("ollamaApiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("vercelAiGatewayApiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("difyApiKey") as Promise<string | undefined>,
|
||||
context.secrets.get("authNonce") as Promise<string | undefined>,
|
||||
store.get("apiKey") as Promise<string | undefined>,
|
||||
store.get("openRouterApiKey") as Promise<string | undefined>,
|
||||
store.get("clineAccountId") as Promise<string | undefined>,
|
||||
store.get("awsAccessKey") as Promise<string | undefined>,
|
||||
store.get("awsSecretKey") as Promise<string | undefined>,
|
||||
store.get("awsSessionToken") as Promise<string | undefined>,
|
||||
store.get("awsBedrockApiKey") as Promise<string | undefined>,
|
||||
store.get("openAiApiKey") as Promise<string | undefined>,
|
||||
store.get("geminiApiKey") as Promise<string | undefined>,
|
||||
store.get("openAiNativeApiKey") as Promise<string | undefined>,
|
||||
store.get("deepSeekApiKey") as Promise<string | undefined>,
|
||||
store.get("requestyApiKey") as Promise<string | undefined>,
|
||||
store.get("togetherApiKey") as Promise<string | undefined>,
|
||||
store.get("qwenApiKey") as Promise<string | undefined>,
|
||||
store.get("doubaoApiKey") as Promise<string | undefined>,
|
||||
store.get("mistralApiKey") as Promise<string | undefined>,
|
||||
store.get("fireworksApiKey") as Promise<string | undefined>,
|
||||
store.get("liteLlmApiKey") as Promise<string | undefined>,
|
||||
store.get("asksageApiKey") as Promise<string | undefined>,
|
||||
store.get("xaiApiKey") as Promise<string | undefined>,
|
||||
store.get("sambanovaApiKey") as Promise<string | undefined>,
|
||||
store.get("cerebrasApiKey") as Promise<string | undefined>,
|
||||
store.get("groqApiKey") as Promise<string | undefined>,
|
||||
store.get("moonshotApiKey") as Promise<string | undefined>,
|
||||
store.get("nebiusApiKey") as Promise<string | undefined>,
|
||||
store.get("huggingFaceApiKey") as Promise<string | undefined>,
|
||||
store.get("sapAiCoreClientId") as Promise<string | undefined>,
|
||||
store.get("sapAiCoreClientSecret") as Promise<string | undefined>,
|
||||
store.get("huaweiCloudMaasApiKey") as Promise<string | undefined>,
|
||||
store.get("basetenApiKey") as Promise<string | undefined>,
|
||||
store.get("zaiApiKey") as Promise<string | undefined>,
|
||||
store.get("ollamaApiKey") as Promise<string | undefined>,
|
||||
store.get("vercelAiGatewayApiKey") as Promise<string | undefined>,
|
||||
store.get("difyApiKey") as Promise<string | undefined>,
|
||||
store.get("authNonce") as Promise<string | undefined>,
|
||||
])
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { type SecretStorage } from "vscode"
|
||||
import { ClineStorage } from "../ClineStorage"
|
||||
import { CredentialStorage } from "../credential"
|
||||
import { ClineSecretStorage } from "../secrets"
|
||||
|
||||
export type ClineStorages = ClineStorage | ClineSecretStorage | CredentialStorage | SecretStorage
|
||||
|
||||
export interface ClineStorageChangeEvent {
|
||||
readonly key: string
|
||||
}
|
||||
|
||||
export type StorageEventListener = (event: ClineStorageChangeEvent) => Promise<void>
|
||||
@@ -30,6 +30,7 @@ import { fixWithCline } from "./core/controller/commands/fixWithCline"
|
||||
import { improveWithCline } from "./core/controller/commands/improveWithCline"
|
||||
import { sendAddToInputEvent } from "./core/controller/ui/subscribeToAddToInput"
|
||||
import { sendFocusChatInputEvent } from "./core/controller/ui/subscribeToFocusChatInput"
|
||||
import { secretStorage } from "./core/storage/secrets"
|
||||
import { workspaceResolver } from "./core/workspace"
|
||||
import { focusChatInput, getContextForCommand } from "./hosts/vscode/commandUtils"
|
||||
import { VscodeDiffViewProvider } from "./hosts/vscode/VscodeDiffViewProvider"
|
||||
@@ -55,6 +56,8 @@ https://github.com/microsoft/vscode-webview-ui-toolkit-samples/tree/main/framewo
|
||||
export async function activate(context: vscode.ExtensionContext) {
|
||||
setupHostProvider(context)
|
||||
|
||||
secretStorage.init(context.secrets)
|
||||
|
||||
const sidebarWebview = (await initialize(context)) as VscodeWebviewProvider
|
||||
|
||||
Logger.log("Cline extension activated")
|
||||
|
||||
@@ -4,6 +4,7 @@ import path, { join } from "path"
|
||||
import type { Extension, ExtensionContext } from "vscode"
|
||||
import { ExtensionKind, ExtensionMode } from "vscode"
|
||||
import { URI } from "vscode-uri"
|
||||
import { CredentialStorage } from "@/core/storage/credential"
|
||||
import { log } from "./utils"
|
||||
import { EnvironmentVariableCollection, MementoStore, readJson, SecretStore } from "./vscode-context-utils"
|
||||
|
||||
@@ -37,7 +38,9 @@ const extensionContext: ExtensionContext = {
|
||||
|
||||
// Set up KV stores.
|
||||
globalState: new MementoStore(path.join(DATA_DIR, "globalState.json")),
|
||||
secrets: new SecretStore(path.join(DATA_DIR, "secrets.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")),
|
||||
|
||||
// Set up URIs.
|
||||
storageUri: URI.file(DATA_DIR),
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import os from "node:os"
|
||||
|
||||
// Determine platform and architecture at runtime once.
|
||||
const _platform = os.platform()
|
||||
|
||||
export enum PLATFORM_OS {
|
||||
MacOS = "darwin",
|
||||
Linux = "linux",
|
||||
Win32 = "win32",
|
||||
}
|
||||
|
||||
export function getPlatformOS(): PLATFORM_OS {
|
||||
switch (_platform) {
|
||||
case "darwin":
|
||||
return PLATFORM_OS.MacOS
|
||||
case "win32":
|
||||
return PLATFORM_OS.Win32
|
||||
default:
|
||||
return PLATFORM_OS.Linux
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user