Compare commits

...
Author SHA1 Message Date
kvyb 6001b7a9c2 fix: prevent macOS system prompts in keychain E2E tests 2025-10-07 22:40:14 +08:00
kvyb 2d44337542 test: add more test cases to keychain secrets e2e test 2025-10-07 22:33:27 +08:00
kvyb f3e0337f73 fix: testing works with keychains, migration verification, and CRUD operations. Fixes security CLI argument ordering and credential.ts error handling. 2025-10-07 00:22:43 +08:00
kvyb 052ff38e16 fix: simplify secret sync; align APIs; mac-only E2E 2025-10-06 19:36:33 +08:00
kvyb a5bdef041a fix: macOS-only keychain + migration; keep Linux/Windows on legacy 2025-10-06 19:02:13 +08:00
kvyb 5847032f82 Merge origin/main into kvyb/cline-standalone-secrets-storage: adopt main structure, integrate secrets OS keychain + migration; fix tests and CI; remove unsupported shutdown RPC 2025-10-06 18:10:23 +08:00
kvyb 3a0c74e265 test: standalone migration test deterministic, not guessing wait 2025-09-23 09:41:47 +08:00
kvyb f18fd1f4c7 test: isolate extension and standalone tests 2025-09-23 09:22:21 +08:00
kvyb cafe3d4702 test: split standalone vs extension tests; run standalone secrets migration in CI 2025-09-23 09:11:09 +08:00
kvyb 479d139823 test: set minimal permissions for e2e workflow 2025-09-23 08:58:17 +08:00
kvyb 4ad9b02d8c fix E2E workflow YAML 2025-09-23 08:53:39 +08:00
kvyb 11b94a802d fix: make legacy secrets migration synchronous with logging; keep graceful fallback 2025-09-23 08:40:51 +08:00
kvyb f488ba3cbe logging: guard HostProvider.get().logToChannel with try/catch; console fallback for early startup (IntelliJ plugin) 2025-09-23 07:58:44 +08:00
kvyb 84ec57b92d Merge remote-tracking branch 'origin/main' into kvyb/cline-standalone-secrets-storage
# Conflicts:
#	webview-ui/package-lock.json
2025-09-23 07:14:45 +08:00
kvyb db14e41077 test: relax timing tolerance to avoid windows ci flake 2025-09-22 14:43:49 +08:00
kvyb e0c81e094b test: skip CredentialStorage unit tests on Windows; keychain test still covered via E2E shell tests 2025-09-22 14:22:17 +08:00
kvyb 78360bde4c test: try to reliably install credential manager 2025-09-22 14:00:50 +08:00
kvyb 5f8ee85596 test: install CredentialManager in both pwsh and powershell.exe 2025-09-22 13:50:55 +08:00
kvyb 9c5e33e705 fix: skip standalone packaging in test workflow 2025-09-22 13:11:26 +08:00
kvyb 75a7a3b0d9 fix: test stub clineClientId in setupTests to silence client ID error in CI 2025-09-22 12:50:07 +08:00
kvyb 409726b557 fix: secrets test to avoid vscode import; add win CredentialManager install; run windows deps/no-deps 2025-09-19 16:44:01 +08:00
kvyb 1c79eeca6b Merge: align state-helpers with main; wire standalone secrets to keychain; fix cline-core toast 2025-09-19 16:04:59 +08:00
kvyb 927284c314 Merge remote-tracking branch 'origin/main' into kvyb/cline-standalone-secrets-storage 2025-09-19 16:03:56 +08:00
kvyb 9aef9f9a33 fix: e2e linux deps + no-deps in one test matrix 2025-09-19 10:47:41 +08:00
kvyb 6ac3fdc43f feat: deps probe + toast; file fallback; tests for secrets 2025-09-19 10:12:13 +08:00
kvyb c4f9a83bd7 import CredentialStorage unit test 2025-09-19 09:54:51 +08:00
kvyb ed86631eec Add e2e test for secrets in OS keychain 2025-09-16 15:26:37 +08:00
kvyb 9cdf09f3ee refactor: Use OS keychain for standalone; migrate secrets atomically 2025-09-16 06:32:43 +08:00
abeatrix cf183463b7 Use Logger & add filePath based storage 2025-09-11 17:57:10 -07:00
abeatrix 5dc1163a56 Merge branch 'main' into bee/cline-secrets-storage 2025-09-11 14:30:08 -07:00
abeatrix 44d142b21c Clean up 2025-09-11 12:00:24 -07:00
abeatrix 3d9b45ba49 Integrate credential storage
- Replace SecretStore with CredentialStorage in vscode-context
- Add fallback to SecretStore if CredentialStorage unavailable
2025-09-10 23:15:24 -07:00
abeatrix b2874bdfaf Add credential storage abstraction with platform-specific backends
- Create new credential storage system with support for macOS Keychain, Windows Credential Manager, and Linux secret-tool
- Add secrets storage abstraction layer to decouple from VS Code's secret storage
- Refactor StateManager to use new storage abstractions instead of direct VS Code context
- Add type definitions and utilities for storage system
- Enable credential storage for standalone environments
2025-09-10 23:08:56 -07:00
25 changed files with 1723 additions and 111 deletions
+89 -91
View File
@@ -1,108 +1,106 @@
name: E2E Tests
on:
push:
branches:
- main
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
workflow_dispatch:
push:
branches:
- main
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
matrix_prep:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- id: set-matrix
run: |
echo 'matrix=[{"runner":"ubuntu"},{"runner":"windows"},{"runner":"macos"}]' >> $GITHUB_OUTPUT
matrix_prep:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- id: set-matrix
run: |
echo "matrix=[{\"runner\":\"macos\",\"deps\":\"true\"}]" >> $GITHUB_OUTPUT
e2e:
needs: matrix_prep
strategy:
fail-fast: false
matrix:
include: ${{ fromJson(needs.matrix_prep.outputs.matrix) }}
runs-on: ${{ matrix.runner }}-latest
timeout-minutes: 20
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: 22
e2e:
needs: matrix_prep
strategy:
fail-fast: false
matrix:
include: ${{ fromJson(needs.matrix_prep.outputs.matrix) }}
runs-on: ${{ matrix.runner }}-latest
timeout-minutes: 20
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
# Cache root dependencies - only reuse if package-lock.json exactly matches
- name: Cache root dependencies
uses: actions/cache@v4
id: root-cache
with:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: 22
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
- name: Cache webview-ui dependencies
uses: actions/cache@v4
id: webview-cache
with:
path: webview-ui/node_modules
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
- name: Cache root dependencies
uses: actions/cache@v4
id: root-cache
with:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
# Cache VS Code installation
- name: Cache VS Code
uses: actions/cache@v4
id: vscode-cache
with:
path: .vscode-test
key: vscode-${{ runner.os }}-stable-${{ hashFiles('.vscode-test.mjs', 'package.json') }}
restore-keys: |
vscode-${{ runner.os }}-stable-
- name: Cache webview-ui dependencies
uses: actions/cache@v4
id: webview-cache
with:
path: webview-ui/node_modules
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
# Cache Playwright browsers
- name: Cache Playwright browsers
uses: actions/cache@v4
id: playwright-cache
with:
path: |
~/.cache/ms-playwright
~/Library/Caches/ms-playwright
~/AppData/Local/ms-playwright
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
playwright-browsers-${{ runner.os }}-
- name: Cache VS Code
uses: actions/cache@v4
id: vscode-cache
with:
path: .vscode-test
key: vscode-${{ runner.os }}-stable-${{ hashFiles('.vscode-test.mjs', 'package.json') }}
restore-keys: |
vscode-${{ runner.os }}-stable-
- name: Install root dependencies
if: steps.root-cache.outputs.cache-hit != 'true'
run: npm ci
- name: Cache Playwright browsers
uses: actions/cache@v4
id: playwright-cache
with:
path: |
~/.cache/ms-playwright
~/Library/Caches/ms-playwright
~/AppData/Local/ms-playwright
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
playwright-browsers-${{ runner.os }}-
- name: Install webview-ui dependencies
if: steps.webview-cache.outputs.cache-hit != 'true'
run: cd webview-ui && npm ci
- name: Install root dependencies
if: steps.root-cache.outputs.cache-hit != 'true'
run: npm ci
- name: Install xvfb on Linux
if: matrix.runner == 'ubuntu'
run: sudo apt-get update && sudo apt-get install -y xvfb
- name: Install webview-ui dependencies
if: steps.webview-cache.outputs.cache-hit != 'true'
run: cd webview-ui && npm ci
# Run optimized E2E tests (eliminates redundant builds)
- name: Run E2E tests - Linux
if: matrix.runner == 'ubuntu'
run: xvfb-run -a npm run test:e2e:optimal
- name: Build standalone (macOS)
if: matrix.runner == 'macos'
run: npm run compile-standalone
- name: Run E2E tests (macOS only)
if: matrix.runner == 'macos'
env:
EXPECT_DEPS: ${{ matrix.deps || 'false' }}
run: npm run test:e2e:optimal
- name: Run E2E tests - Non-Linux
if: matrix.runner != 'ubuntu'
run: npm run test:e2e:optimal
- uses: actions/upload-artifact@v4
if: ${{ failure() }}
with:
name: playwright-recordings-${{ matrix.runner }}
path: |
test-results/playwright/
- uses: actions/upload-artifact@v4
if: ${{ failure() }}
with:
name: playwright-recordings-${{ matrix.runner }}
path: |
test-results/playwright/
+13
View File
@@ -197,6 +197,19 @@ async function main() {
} else {
await extensionCtx.rebuild()
await extensionCtx.dispose()
// Also build the migration test utility for E2E tests when building standalone
if (standalone) {
const migrationConfig = {
...baseConfig,
entryPoints: ["src/test/e2e/utils/migrate-secrets.ts"],
outfile: `${destDir}/migrate-secrets.js`,
external: ["vscode", "@grpc/reflection", "grpc-health-check", "better-sqlite3"],
}
const migrationCtx = await esbuild.context(migrationConfig)
await migrationCtx.rebuild()
await migrationCtx.dispose()
}
}
}
+10 -3
View File
@@ -79,7 +79,8 @@ async function main(): Promise<void> {
}
const extensionsDir = path.join(distDir, "vsce-extension")
const userDataDir = mkdtempSync(path.join(os.tmpdir(), "vsce"))
// Respect incoming CLINE_DIR from parent (e.g., E2E tests that seed legacy secrets)
const userDataDir = process.env.CLINE_DIR ? process.env.CLINE_DIR : mkdtempSync(path.join(os.tmpdir(), "vsce"))
const clineTestWorkspace = mkdtempSync(path.join(os.tmpdir(), "cline-test-workspace-"))
console.log("Starting HostBridge test server...")
@@ -134,16 +135,22 @@ async function main(): Promise<void> {
CLINE_DIR: userDataDir,
INSTALL_DIR: extensionsDir,
},
stdio: "inherit",
stdio: "pipe",
})
childProcesses.push(coreService)
// Proxy core service output so callers that pipe this script can observe logs
coreService.stdout?.on("data", (chunk) => process.stdout.write(chunk))
coreService.stderr?.on("data", (chunk) => process.stderr.write(chunk))
const shutdown = async () => {
console.log("\nShutting down services...")
while (childProcesses.length > 0) {
const child = childProcesses.pop()
if (child && !child.killed) child.kill("SIGINT")
if (child && !child.killed) {
child.kill("SIGINT")
}
}
await ClineApiServerMock.stopGlobalServer()
+4
View File
@@ -101,6 +101,7 @@ describe("Retry Decorator", () => {
result.push(value)
}
// main branch: rely on setTimeoutSpy assertions instead of duration checks
callCount.should.equal(2)
setTimeoutSpy.calledOnce.should.be.true
const [_, delay] = setTimeoutSpy.getCall(0).args
@@ -136,6 +137,7 @@ describe("Retry Decorator", () => {
result.push(value)
}
// main branch: rely on setTimeoutSpy assertions instead of duration checks
callCount.should.equal(2)
setTimeoutSpy.calledOnce.should.be.true
@@ -169,6 +171,7 @@ describe("Retry Decorator", () => {
result.push(value)
}
// main branch: rely on setTimeoutSpy assertions instead of duration checks
callCount.should.equal(2)
setTimeoutSpy.calledOnce.should.be.true
const [_, delay] = setTimeoutSpy.getCall(0).args
@@ -202,6 +205,7 @@ describe("Retry Decorator", () => {
result.push(value)
}
// main branch: rely on setTimeoutSpy assertions instead of duration checks
callCount.should.equal(3)
setTimeoutSpy.calledOnce.should.be.true
const [_, delay] = setTimeoutSpy.getCall(0).args
+115
View File
@@ -0,0 +1,115 @@
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): () => void {
this.subscribers.push(callback)
return () => {
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)
}
}
+10 -4
View File
@@ -11,11 +11,11 @@ import {
writeTaskSettingsToStorage,
} from "./disk"
import { STATE_MANAGER_NOT_INITIALIZED } from "./error-messages"
import { secretStorage } from "./secrets"
import {
GlobalState,
GlobalStateAndSettings,
GlobalStateAndSettingsKey,
GlobalStateKey,
LocalState,
LocalStateKey,
SecretKey,
@@ -147,7 +147,7 @@ export class StateManager {
// Then track the keys for persistence
Object.keys(updates).forEach((key) => {
this.pendingGlobalState.add(key as GlobalStateKey)
this.pendingGlobalState.add(key as GlobalStateAndSettingsKey)
})
// Schedule debounced persistence
@@ -267,6 +267,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)
})
@@ -854,9 +860,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
}
// Only run on macOS for now; skip Windows/Linux
const shouldSkip = platform !== "darwin" || !hasCommand("security")
describe("CredentialStorage", () => {
if (shouldSkip) {
console.warn("Skipping CredentialStorage tests: Windows covered via E2E; or OS tool missing")
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)
})
})
})
+198
View File
@@ -0,0 +1,198 @@
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}`)
}
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) {
// Return undefined if the key doesn't exist (expected behavior)
// "The specified item could not be found" is not an error, just means key doesn't exist
if (error instanceof Error && error.message.includes("could not be found")) {
return undefined
}
throw error
}
}
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
}
}
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)
})
}
}
const PLATFORM_COMMANDS: PlatformCommands = {
[PLATFORM_OS.MacOS]: {
get: ({ service, account }) => {
const keychain = process.env.CLINE_KEYCHAIN
const args = ["find-generic-password", "-s", service, "-a", account, "-w"]
// Note: find-generic-password doesn't support -k flag
// If using a custom keychain, specify it at the end as a positional argument
if (keychain && keychain.length > 0) {
args.push(keychain)
}
return { command: "security", args }
},
store: ({ service, account, value }) => {
const keychain = process.env.CLINE_KEYCHAIN
const args = ["add-generic-password", "-s", service, "-a", account]
// For test keychains, allow all apps to access (makes it manageable in Keychain Access)
if (keychain && keychain.length > 0) {
args.push("-A")
}
args.push("-w", value)
// Keychain must be specified as positional argument at the end
if (keychain && keychain.length > 0) {
args.push(keychain)
}
return { command: "security", args }
},
delete: ({ service, account }) => {
const keychain = process.env.CLINE_KEYCHAIN
const args = ["delete-generic-password", "-s", service, "-a", account]
if (keychain && keychain.length > 0) {
args.push("-k", keychain)
}
return { command: "security", args }
},
},
[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 || "",
],
}),
},
}
+75
View File
@@ -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)
}
}
}
+65
View File
@@ -0,0 +1,65 @@
import type { SecretStorage as VSCodeSecretStorage } from "vscode"
import { Logger } from "@/services/logging/Logger"
import { ClineStorage } from "./ClineStorage"
export 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
+1
View File
@@ -8,6 +8,7 @@ import { DEFAULT_DICTATION_SETTINGS, DictationSettings } from "@/shared/Dictatio
import { DEFAULT_FOCUS_CHAIN_SETTINGS } from "@/shared/FocusChainSettings"
import { DEFAULT_MCP_DISPLAY_MODE } from "@/shared/McpDisplayMode"
import { OpenaiReasoningEffort } from "@/shared/storage/types"
import { readTaskHistoryFromState } from "../disk"
import { GlobalStateAndSettings, LocalState, SecretKey, Secrets } from "../state-keys"
export async function readSecretsFromDisk(context: ExtensionContext): Promise<Secrets> {
+12
View File
@@ -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>
@@ -124,7 +124,6 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
const { relPath, absolutePath, fileExists, diff, content, newContent, workspaceContext } = result
// Handle approval flow
const sharedMessageProps: ClineSayTool = {
tool: fileExists ? "editedExistingFile" : "newFileCreated",
+3
View File
@@ -27,6 +27,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 { abortCommitGeneration, generateCommitMessage } from "./hosts/vscode/commit-message-generator"
@@ -52,6 +53,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 webview = (await initialize(context)) as VscodeWebviewProvider
Logger.log("Cline extension activated")
+39
View File
@@ -65,6 +65,7 @@ export class AuthService {
protected _activeAuthStatusUpdateHandlers = new Set<StreamingResponseHandler<AuthState>>()
protected _handlerToController = new Map<StreamingResponseHandler<AuthState>, Controller>()
protected _controller: Controller
private _secretUnsubscribe: (() => void) | undefined
/**
* Creates an instance of AuthService.
@@ -275,6 +276,44 @@ export class AuthService {
}
}
/**
* Start listening to secret changes and keep auth in sync (standalone/host-agnostic).
* Expects a secret storage-like object with onDidChange and get.
*/
startSecretSync(secretStorage: {
onDidChange: (listener: (e: { key: string }) => any) => (() => void) | { dispose(): void }
get: (key: string) => Promise<string | undefined>
}): () => void {
// Clean any existing subscription first
try {
this._secretUnsubscribe?.()
} catch {}
this._secretUnsubscribe = undefined
const sub = secretStorage.onDidChange(async ({ key }) => {
if (key !== "clineAccountId") {
return
}
const value = await secretStorage.get("clineAccountId")
const authService = AuthService.getInstance(this._controller)
if (value) {
authService?.restoreRefreshTokenAndRetrieveAuthInfo()
} else {
authService?.handleDeauth()
}
})
this._secretUnsubscribe = typeof sub === "function" ? sub : () => sub.dispose()
return this._secretUnsubscribe
}
/** Stop listening to secret changes */
stopSecretSync(): void {
try {
this._secretUnsubscribe?.()
} catch {}
this._secretUnsubscribe = undefined
}
/**
* Subscribe to authStatusUpdate events
* @param controller The controller instance
+7 -1
View File
@@ -32,7 +32,13 @@ export class Logger {
if (error?.message) {
fullMessage += ` ${error.message}`
}
HostProvider.get().logToChannel(`${level} ${fullMessage}`)
// During early startup, HostProvider may not be initialized yet.
// In that case, fall back to console logging instead of throwing.
try {
HostProvider.get().logToChannel(`${level} ${fullMessage}`)
} catch {
console.log(`${level} ${fullMessage}`)
}
if (error?.stack) {
console.log(`Stack trace:\n${error.stack}`)
}
+25 -6
View File
@@ -1,18 +1,20 @@
import { ExternalDiffViewProvider } from "@hosts/external/ExternalDiffviewProvider"
import { ExternalWebviewProvider } from "@hosts/external/ExternalWebviewProvider"
import { ExternalHostBridgeClientManager } from "@hosts/external/host-bridge-client-manager"
import { retryOperation } from "@utils/retry"
import * as path from "path"
import { initialize, tearDown } from "@/common"
import { SqliteLockManager } from "@/core/locks/SqliteLockManager"
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 { ShowMessageType } from "@/shared/proto/host/window"
import { HOSTBRIDGE_PORT, waitForHostBridgeReady } from "./hostbridge-client"
import { PROTOBUS_PORT, startProtobusService } from "./protobus-service"
import { log } from "./utils"
import { initializeContext } from "./vscode-context"
import { getStandaloneDepsWarning, initializeContext, runLegacySecretsMigrationIfNeeded } from "./vscode-context"
let globalLockManager: SqliteLockManager | undefined
@@ -55,6 +57,9 @@ async function main() {
// The host bridge should be available before creating the host provider because it depends on the host bridge.
setupHostProvider(extensionContext, EXTENSION_DIR, DATA_DIR)
// Ensure legacy secrets are migrated to OS keychain before any reads during initialization
await runLegacySecretsMigrationIfNeeded()
const webviewProvider = await initialize(extensionContext)
// Enable the localhost HTTP server that handles auth redirects.
@@ -63,6 +68,16 @@ async function main() {
// Now this will throw instead of exit if binding fails
const protobusAddress = await startProtobusService(webviewProvider.controller)
// Non-blocking info when OS keychain deps are missing (Linux/Windows)
const depsWarning = getStandaloneDepsWarning()
if (depsWarning) {
void HostProvider.window.showMessage({ type: ShowMessageType.INFORMATION, message: depsWarning })
}
// Mirror VS Code behavior: react to clineAccountId secret changes (login/logout)
const authService = AuthService.getInstance(webviewProvider.controller)
authService.startSecretSync(secretStorage)
// Initialize SQLite lock manager for instance registration
const dbPath = `${DATA_DIR}/locks.db`
globalLockManager = new SqliteLockManager({
@@ -162,10 +177,9 @@ function setupGlobalErrorHandlers() {
*/
async function requestHostBridgeShutdown(): Promise<void> {
try {
await retryOperation(3, 2000, async () => {
await HostProvider.env.shutdown({})
})
log("Host bridge shutdown requested successfully")
// Shutdown RPC is not exposed in the current EnvService client for TS runtime.
// Best-effort: log and skip since the host bridge lifecycle is managed externally.
log("Host bridge shutdown RPC not available in TS client; skipping request")
} catch (error) {
log(`Warning: Failed to request host bridge shutdown: ${error}`)
log("Proceeding with cleanup")
@@ -189,6 +203,11 @@ async function shutdownGracefully(lockManager?: SqliteLockManager) {
log("Warning: HostProvider not initialized, cannot request shutdown")
}
// Stop secret sync listener
try {
AuthService.getInstance().stopSecretSync()
} catch {}
// Step 2: Clean up lock manager entry
log("Cleaning up lock manager entry...")
try {
+35
View File
@@ -28,6 +28,41 @@ export class SecretStore implements vscode.SecretStorage {
}
}
// A SecretStorage implementation that delegates to an injected storage
// (e.g., the cline secretStorage singleton backed by OS keychain).
export class DelegatingSecretStore implements vscode.SecretStorage {
private readonly _onDidChange = new EventEmitter<vscode.SecretStorageChangeEvent>()
constructor(
private readonly delegate: {
get(key: string): Promise<string | undefined>
store(key: string, value: string): Promise<void>
delete(key: string): Promise<void>
onDidChange?: (listener: (e: { key: string }) => any) => { dispose(): void } | (() => void)
},
) {
if (this.delegate.onDidChange) {
const sub = this.delegate.onDidChange(({ key }) => this._onDidChange.fire({ key }))
// No instance-level storage required; delegate owns lifecycle.
void (typeof sub === "function" ? { dispose: sub } : sub)
}
}
readonly onDidChange: vscode.Event<vscode.SecretStorageChangeEvent> = this._onDidChange.event
get(key: string): Thenable<string | undefined> {
return this.delegate.get(key)
}
store(key: string, value: string): Thenable<void> {
return this.delegate.store(key, value)
}
delete(key: string): Thenable<void> {
return this.delegate.delete(key)
}
}
// Create a class that implements Memento interface with the required setKeysForSync method
export class MementoStore implements vscode.Memento {
private data: JsonKeyValueStore<any>
+142 -5
View File
@@ -1,19 +1,28 @@
import { mkdirSync } from "node:fs"
import { spawnSync } from "node:child_process"
import { existsSync, mkdirSync } from "fs"
import os from "os"
import path from "path"
import type { Extension, ExtensionContext } from "vscode"
import { ExtensionKind, ExtensionMode } from "vscode"
import { URI } from "vscode-uri"
import { ClineStorage } from "@/core/storage/ClineStorage"
import { CredentialStorage } from "@/core/storage/credential"
import { FileBasedStorage } from "@/core/storage/file"
import { secretStorage } from "@/core/storage/secrets"
import { ExtensionRegistryInfo } from "@/registry"
import { log } from "./utils"
import { EnvironmentVariableCollection, MementoStore, readJson, SecretStore } from "./vscode-context-utils"
import { DelegatingSecretStore, EnvironmentVariableCollection, MementoStore, readJson, SecretStore } from "./vscode-context-utils"
log("Running standalone cline", ExtensionRegistryInfo.version)
log(`CLINE_ENVIRONMENT: ${process.env.CLINE_ENVIRONMENT}`)
// WE WILL HAVE TO MIGRATE THIS FROM DATA TO v1 LATER
const SETTINGS_SUBFOLDER = "data"
// Module-level vars used by migration/helpers
let STANDALONE_DEPS_WARNING: string | undefined
let SECRETS_FILE: string
let standaloneBackend: ClineStorage | null = null
export function initializeContext(clineDir?: string) {
const CLINE_DIR = clineDir || process.env.CLINE_DIR || `${os.homedir()}/.cline`
const DATA_DIR = path.join(CLINE_DIR, SETTINGS_SUBFOLDER)
@@ -24,15 +33,30 @@ export function initializeContext(clineDir?: string) {
mkdirSync(WORKSPACE_STORAGE_DIR, { recursive: true })
log("Using settings dir:", DATA_DIR)
// Initialize the unified secret storage backend for standalone
SECRETS_FILE = path.join(DATA_DIR, "secrets.json")
standaloneBackend = selectStandaloneSecrets(DATA_DIR)
secretStorage.init(standaloneBackend)
const EXTENSION_DIR = path.join(INSTALL_DIR, "extension")
const EXTENSION_MODE = process.env.IS_DEV === "true" ? ExtensionMode.Development : ExtensionMode.Production
// In minimal contexts (e.g., migration tests), the packaged extension assets may not exist.
// Read package.json best-effort to avoid crashing during migrate-only runs.
let extensionPackageJson: any
try {
const pkgPath = path.join(EXTENSION_DIR, "package.json")
extensionPackageJson = existsSync(pkgPath) ? readJson(pkgPath) : { name: "cline-standalone", version: "0.0.0" }
} catch {
extensionPackageJson = { name: "cline-standalone", version: "0.0.0" }
}
const extension: Extension<void> = {
id: ExtensionRegistryInfo.id,
isActive: true,
extensionPath: EXTENSION_DIR,
extensionUri: URI.file(EXTENSION_DIR),
packageJSON: readJson(path.join(EXTENSION_DIR, "package.json")),
packageJSON: extensionPackageJson,
exports: undefined, // There are no API exports in the standalone version.
activate: async () => {},
extensionKind: ExtensionKind.UI,
@@ -44,7 +68,11 @@ export function initializeContext(clineDir?: string) {
// Set up KV stores.
globalState: new MementoStore(path.join(DATA_DIR, "globalState.json")),
secrets: new SecretStore(path.join(DATA_DIR, "secrets.json")),
// Note: core reads/writes secrets via the singleton; context.secrets remains for compatibility
secrets:
standaloneBackend instanceof CredentialStorage
? new DelegatingSecretStore(secretStorage)
: new SecretStore(SECRETS_FILE),
// Set up URIs.
storageUri: URI.file(WORKSPACE_STORAGE_DIR),
@@ -76,3 +104,112 @@ export function initializeContext(clineDir?: string) {
EXTENSION_DIR,
}
}
// Select the best standalone secret storage backend
// For now: macOS -> OS keychain; Linux/Windows -> legacy file-based storage
function selectStandaloneSecrets(dataDir: string) {
try {
if (process.platform === "darwin") {
if (hasCommand("security")) {
return new CredentialStorage()
} else {
STANDALONE_DEPS_WARNING = "macOS 'security' tool not available; using file-based secrets"
return new FileBasedStorage(path.join(dataDir, "secrets.json"))
}
}
} catch (error) {
log(`Credential backend selection error; falling back to file store: ${String(error)}`)
}
// Default: legacy file-based storage on non-macOS
return new FileBasedStorage(path.join(dataDir, "secrets.json"))
}
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)}`)
}
}
export async function runLegacySecretsMigrationIfNeeded(): Promise<void> {
try {
if (process.platform === "darwin" && standaloneBackend instanceof CredentialStorage) {
log("Starting legacy secrets migration to OS keychain...")
await migrateFileSecretsToOS(SECRETS_FILE)
log("Legacy secrets migration completed.")
} else {
// Graceful fallback to file-based storage; no migration needed
log("OS keychain unavailable; using file-based secrets. Skipping migration.")
}
} catch (error) {
log(`Legacy secrets migration error: ${String(error)}`)
}
}
// Test-only export to directly invoke migration logic without starting services
export const __test_migrateFileSecretsToOS = migrateFileSecretsToOS
// Expose any dependency warning to be shown by the host after initialization
export function getStandaloneDepsWarning(): string | undefined {
return STANDALONE_DEPS_WARNING
}
+69
View File
@@ -0,0 +1,69 @@
import { spawnSync } from "node:child_process"
import os from "node:os"
import { expect } from "@playwright/test"
import { e2e } from "./utils/helpers"
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
}
function macStore(service: string, account: string, value: string): boolean {
// delete first for idempotency
spawnSync("security", ["delete-generic-password", "-a", account, "-s", service], { stdio: "ignore" })
const res = spawnSync("security", ["add-generic-password", "-a", account, "-s", service, "-w", value, "-U"], {
stdio: "ignore",
})
return res.status === 0
}
function macGet(service: string, account: string): string | undefined {
const res = spawnSync("security", ["find-generic-password", "-a", account, "-s", service, "-w"], { encoding: "utf8" })
return res.status === 0 ? res.stdout.trim() : undefined
}
function macDelete(service: string, account: string): boolean {
const res = spawnSync("security", ["delete-generic-password", "-a", account, "-s", service], { stdio: "ignore" })
return res.status === 0
}
// Linux/Windows helpers removed for now since test is macOS-only
// Extension-host validation of OS keychain commands
e2e("Secrets - OS keychain get/store/delete (macOS only)", async () => {
const platform = os.platform()
const service = "cline"
const key = `e2e_secret_${Date.now()}`
const value = "test-secret"
if (platform !== "darwin") {
console.warn("Skipping: test is macOS-only for now")
return
}
if (!hasCommand("security")) {
console.warn("Skipping: security CLI not available on macOS runner")
return
}
// Ensure clean slate
macDelete(service, key)
// Store
const stored = macStore(service, key, value)
expect(stored).toBeTruthy()
// Get
const fetched = macGet(service, key)
expect(fetched).toBe(value)
// Delete
const deleted = macDelete(service, key)
expect(deleted).toBeTruthy()
const after = macGet(service, key)
expect(after).toBeUndefined()
})
// Standalone deps warning moved to standalone-migration.test.ts
+475
View File
@@ -0,0 +1,475 @@
import { ChildProcess, spawn, spawnSync } from "node:child_process"
import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import { expect, test } from "@playwright/test"
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
}
function setupTestKeychain(): { keychainPath: string; keychainPwd: string; cleanup: () => void } {
const keychainPath = path.join(os.tmpdir(), `cline-tests-${Date.now()}-${Math.random().toString(36).slice(2)}.keychain-db`)
const keychainPwd = "cline-test-pass"
// Save original keychain search list to restore later (prevents system prompts)
const originalKeychains = spawnSync("security", ["list-keychains", "-d", "user"], { encoding: "utf8" }).stdout
// Create and configure test keychain
spawnSync("security", ["create-keychain", "-p", keychainPwd, keychainPath], { stdio: "ignore" })
spawnSync("security", ["set-keychain-settings", "-lut", "3600", keychainPath], { stdio: "ignore" })
spawnSync("security", ["unlock-keychain", "-p", keychainPwd, keychainPath], { stdio: "ignore" })
// Remove test keychain from search list to prevent system processes from accessing it
const keychainList = originalKeychains
.split("\n")
.map((line) => line.trim().replace(/^"|"$/g, ""))
.filter((line) => line.length > 0)
if (keychainList.length > 0) {
spawnSync("security", ["list-keychains", "-d", "user", "-s", ...keychainList], { stdio: "ignore" })
}
return {
keychainPath,
keychainPwd,
cleanup: () => {
spawnSync("security", ["delete-keychain", keychainPath], { stdio: "ignore" })
},
}
}
test.setTimeout(90000)
test("Standalone migration moves secrets.json to OS keychain and removes file (macOS only)", async () => {
const platform = os.platform()
if (platform !== "darwin") {
test.skip(true, "Only macOS migration is supported at this time")
return
}
if (!hasCommand("security")) {
test.skip(true, "security CLI not available on macOS runner")
return
}
// Prepare isolated CLINE_DIR with legacy secrets.json
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "cline-standalone-mig-"))
const dataDir = path.join(userDataDir, "data")
fs.mkdirSync(dataDir, { recursive: true })
const secretsPath = path.join(dataDir, "secrets.json")
fs.writeFileSync(secretsPath, JSON.stringify({ openRouterApiKey: "migrate-me" }, null, 2))
// Ephemeral, unlocked test keychain to avoid GUI prompts
const { keychainPath, cleanup } = setupTestKeychain()
try {
// Ensure no preexisting key in test keychain
const service = "Cline: openRouterApiKey"
const account = "cline_openRouterApiKey"
spawnSync("security", ["delete-generic-password", "-s", service, "-a", account, "-k", keychainPath], { stdio: "ignore" })
// Run the migration script (compiled from standalone build)
const procEnv = { ...process.env, CLINE_DIR: userDataDir, CLINE_KEYCHAIN: keychainPath }
// Verify migration script exists
const migrationScript = path.join(process.cwd(), "dist-standalone", "migrate-secrets.js")
if (!fs.existsSync(migrationScript)) {
throw new Error("Migration script not found. Run: npm run compile-standalone")
}
const migrator: ChildProcess = spawn("node", [migrationScript], { stdio: "pipe", env: procEnv })
let output = ""
migrator.stdout?.on("data", (d) => {
output += String(d)
})
migrator.stderr?.on("data", (d) => {
output += String(d)
})
// Wait for migration to complete by polling for file removal (source of truth)
const end = Date.now() + 60000
let successSeen = false
let abortSeen = false
while (Date.now() < end) {
// If we saw an abort, surface immediately
abortSeen = /Secrets migration aborted and rolled back|Migration from secrets\.json failed/i.test(output)
if (abortSeen) {
break
}
if (!fs.existsSync(secretsPath)) {
successSeen = true
break
}
await new Promise((r) => setTimeout(r, 250))
}
try {
migrator.kill("SIGINT")
} catch {
// Ignore if already exited
}
if (abortSeen || !successSeen) {
console.log("Migration output:", output)
}
expect(abortSeen).toBeFalsy()
expect(successSeen).toBeTruthy()
// File should be removed
expect(fs.existsSync(secretsPath)).toBeFalsy()
// Quick keychain presence check (5s max)
let present = false
const checkStart = Date.now()
while (Date.now() - checkStart < 5000) {
// Keychain path must be positional argument at the end, not with -k flag
const status = spawnSync("security", ["find-generic-password", "-s", service, "-a", account, "-w", keychainPath], {
stdio: "ignore",
}).status
if (status === 0) {
present = true
break
}
await new Promise((r) => setTimeout(r, 250))
}
expect(present).toBeTruthy()
} finally {
cleanup()
}
})
test("Migration handles empty secrets.json gracefully (macOS only)", async () => {
const platform = os.platform()
if (platform !== "darwin") {
test.skip(true, "Only macOS migration is supported at this time")
return
}
if (!hasCommand("security")) {
test.skip(true, "security CLI not available on macOS runner")
return
}
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "cline-empty-secrets-"))
const dataDir = path.join(userDataDir, "data")
fs.mkdirSync(dataDir, { recursive: true })
const secretsPath = path.join(dataDir, "secrets.json")
fs.writeFileSync(secretsPath, JSON.stringify({}, null, 2))
const { keychainPath, cleanup } = setupTestKeychain()
try {
const procEnv = { ...process.env, CLINE_DIR: userDataDir, CLINE_KEYCHAIN: keychainPath }
const migrationScript = path.join(process.cwd(), "dist-standalone", "migrate-secrets.js")
if (!fs.existsSync(migrationScript)) {
throw new Error("Migration script not found. Run: npm run compile-standalone")
}
const migrator: ChildProcess = spawn("node", [migrationScript], { stdio: "pipe", env: procEnv })
let output = ""
migrator.stdout?.on("data", (d) => {
output += String(d)
})
migrator.stderr?.on("data", (d) => {
output += String(d)
})
const end = Date.now() + 30000
let completed = false
while (Date.now() < end) {
if (/MIGRATION_DONE/i.test(output)) {
completed = true
break
}
await new Promise((r) => setTimeout(r, 250))
}
try {
migrator.kill("SIGINT")
} catch {}
expect(completed).toBeTruthy()
// Empty secrets should not cause errors
expect(/MIGRATION_FAILED|error/i.test(output)).toBeFalsy()
} finally {
cleanup()
}
})
test("Migration handles multiple secrets correctly (macOS only)", async () => {
const platform = os.platform()
if (platform !== "darwin") {
test.skip(true, "Only macOS migration is supported at this time")
return
}
if (!hasCommand("security")) {
test.skip(true, "security CLI not available on macOS runner")
return
}
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "cline-multi-secrets-"))
const dataDir = path.join(userDataDir, "data")
fs.mkdirSync(dataDir, { recursive: true })
const secretsPath = path.join(dataDir, "secrets.json")
const testSecrets = {
openRouterApiKey: "test-openrouter-key-123",
anthropicApiKey: "test-anthropic-key-456",
openAiApiKey: "test-openai-key-789",
}
fs.writeFileSync(secretsPath, JSON.stringify(testSecrets, null, 2))
const { keychainPath, cleanup } = setupTestKeychain()
try {
// Clean up any pre-existing test keys
for (const key of Object.keys(testSecrets)) {
const service = `Cline: ${key}`
const account = `cline_${key}`
spawnSync("security", ["delete-generic-password", "-s", service, "-a", account, "-k", keychainPath], {
stdio: "ignore",
})
}
const procEnv = { ...process.env, CLINE_DIR: userDataDir, CLINE_KEYCHAIN: keychainPath }
const migrationScript = path.join(process.cwd(), "dist-standalone", "migrate-secrets.js")
if (!fs.existsSync(migrationScript)) {
throw new Error("Migration script not found. Run: npm run compile-standalone")
}
const migrator: ChildProcess = spawn("node", [migrationScript], { stdio: "pipe", env: procEnv })
const end = Date.now() + 60000
let successSeen = false
while (Date.now() < end) {
if (!fs.existsSync(secretsPath)) {
successSeen = true
break
}
await new Promise((r) => setTimeout(r, 250))
}
try {
migrator.kill("SIGINT")
} catch {}
expect(successSeen).toBeTruthy()
expect(fs.existsSync(secretsPath)).toBeFalsy()
// Verify all three secrets are in keychain
for (const [key, expectedValue] of Object.entries(testSecrets)) {
const service = `Cline: ${key}`
const account = `cline_${key}`
const result = spawnSync("security", ["find-generic-password", "-s", service, "-a", account, "-w", keychainPath], {
encoding: "utf8",
})
expect(result.status).toBe(0)
expect(result.stdout.trim()).toBe(expectedValue)
}
} finally {
cleanup()
}
})
test("Migration preserves special characters in secret values (macOS only)", async () => {
const platform = os.platform()
if (platform !== "darwin") {
test.skip(true, "Only macOS migration is supported at this time")
return
}
if (!hasCommand("security")) {
test.skip(true, "security CLI not available on macOS runner")
return
}
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "cline-special-chars-"))
const dataDir = path.join(userDataDir, "data")
fs.mkdirSync(dataDir, { recursive: true })
const secretsPath = path.join(dataDir, "secrets.json")
// Test with base64-like characters and special symbols
const specialValue = "sk-test_ABC123+/=xyz!@#$%"
fs.writeFileSync(secretsPath, JSON.stringify({ openRouterApiKey: specialValue }, null, 2))
const { keychainPath, cleanup } = setupTestKeychain()
try {
const service = "Cline: openRouterApiKey"
const account = "cline_openRouterApiKey"
spawnSync("security", ["delete-generic-password", "-s", service, "-a", account, "-k", keychainPath], { stdio: "ignore" })
const procEnv = { ...process.env, CLINE_DIR: userDataDir, CLINE_KEYCHAIN: keychainPath }
const migrationScript = path.join(process.cwd(), "dist-standalone", "migrate-secrets.js")
if (!fs.existsSync(migrationScript)) {
throw new Error("Migration script not found. Run: npm run compile-standalone")
}
const migrator: ChildProcess = spawn("node", [migrationScript], { stdio: "pipe", env: procEnv })
const end = Date.now() + 60000
let successSeen = false
while (Date.now() < end) {
if (!fs.existsSync(secretsPath)) {
successSeen = true
break
}
await new Promise((r) => setTimeout(r, 250))
}
try {
migrator.kill("SIGINT")
} catch {}
expect(successSeen).toBeTruthy()
// Verify exact value preservation
const result = spawnSync("security", ["find-generic-password", "-s", service, "-a", account, "-w", keychainPath], {
encoding: "utf8",
})
expect(result.status).toBe(0)
expect(result.stdout.trim()).toBe(specialValue)
} finally {
cleanup()
}
})
test("Migration is idempotent - running twice is safe (macOS only)", async () => {
const platform = os.platform()
if (platform !== "darwin") {
test.skip(true, "Only macOS migration is supported at this time")
return
}
if (!hasCommand("security")) {
test.skip(true, "security CLI not available on macOS runner")
return
}
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "cline-idempotent-"))
const dataDir = path.join(userDataDir, "data")
fs.mkdirSync(dataDir, { recursive: true })
const secretsPath = path.join(dataDir, "secrets.json")
const testValue = "idempotent-test-key"
fs.writeFileSync(secretsPath, JSON.stringify({ openRouterApiKey: testValue }, null, 2))
const { keychainPath, cleanup } = setupTestKeychain()
try {
const service = "Cline: openRouterApiKey"
const account = "cline_openRouterApiKey"
spawnSync("security", ["delete-generic-password", "-s", service, "-a", account, "-k", keychainPath], { stdio: "ignore" })
const procEnv = { ...process.env, CLINE_DIR: userDataDir, CLINE_KEYCHAIN: keychainPath }
const migrationScript = path.join(process.cwd(), "dist-standalone", "migrate-secrets.js")
if (!fs.existsSync(migrationScript)) {
throw new Error("Migration script not found. Run: npm run compile-standalone")
}
// First migration
const migrator1: ChildProcess = spawn("node", [migrationScript], { stdio: "pipe", env: procEnv })
const end1 = Date.now() + 60000
let success1 = false
while (Date.now() < end1) {
if (!fs.existsSync(secretsPath)) {
success1 = true
break
}
await new Promise((r) => setTimeout(r, 250))
}
try {
migrator1.kill("SIGINT")
} catch {}
expect(success1).toBeTruthy()
// Recreate secrets.json to simulate a second migration attempt
fs.writeFileSync(secretsPath, JSON.stringify({ openRouterApiKey: "different-value" }, null, 2))
// Second migration - should handle pre-existing keychain entry
const migrator2: ChildProcess = spawn("node", [migrationScript], { stdio: "pipe", env: procEnv })
let output2 = ""
migrator2.stdout?.on("data", (d) => {
output2 += String(d)
})
migrator2.stderr?.on("data", (d) => {
output2 += String(d)
})
const end2 = Date.now() + 60000
let success2 = false
while (Date.now() < end2) {
if (!fs.existsSync(secretsPath)) {
success2 = true
break
}
await new Promise((r) => setTimeout(r, 250))
}
try {
migrator2.kill("SIGINT")
} catch {}
expect(success2).toBeTruthy()
// No errors should occur
expect(/MIGRATION_FAILED|error/i.test(output2)).toBeFalsy()
// Verify keychain still has a value (either original or updated)
const result = spawnSync("security", ["find-generic-password", "-s", service, "-a", account, "-w", keychainPath], {
encoding: "utf8",
})
expect(result.status).toBe(0)
expect(result.stdout.trim().length).toBeGreaterThan(0)
} finally {
cleanup()
}
})
test("Migration skips when secrets.json doesn't exist (macOS only)", async () => {
const platform = os.platform()
if (platform !== "darwin") {
test.skip(true, "Only macOS migration is supported at this time")
return
}
if (!hasCommand("security")) {
test.skip(true, "security CLI not available on macOS runner")
return
}
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "cline-no-secrets-"))
const dataDir = path.join(userDataDir, "data")
fs.mkdirSync(dataDir, { recursive: true })
// Don't create secrets.json
const { keychainPath, cleanup } = setupTestKeychain()
try {
const procEnv = { ...process.env, CLINE_DIR: userDataDir, CLINE_KEYCHAIN: keychainPath }
const migrationScript = path.join(process.cwd(), "dist-standalone", "migrate-secrets.js")
if (!fs.existsSync(migrationScript)) {
throw new Error("Migration script not found. Run: npm run compile-standalone")
}
const migrator: ChildProcess = spawn("node", [migrationScript], { stdio: "pipe", env: procEnv })
let output = ""
migrator.stdout?.on("data", (d) => {
output += String(d)
})
migrator.stderr?.on("data", (d) => {
output += String(d)
})
const end = Date.now() + 30000
let completed = false
while (Date.now() < end) {
if (/MIGRATION_DONE/i.test(output)) {
completed = true
break
}
await new Promise((r) => setTimeout(r, 250))
}
try {
migrator.kill("SIGINT")
} catch {}
expect(completed).toBeTruthy()
// Should not error when file doesn't exist
expect(/MIGRATION_FAILED|error/i.test(output)).toBeFalsy()
} finally {
cleanup()
}
})
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env node
/**
* Minimal migration script for E2E testing.
* This runs only the migration logic without starting the full server.
*/
import { initializeContext, runLegacySecretsMigrationIfNeeded } from "../../../standalone/vscode-context"
async function main() {
const clineDir = process.env.CLINE_DIR
if (!clineDir) {
console.error("CLINE_DIR environment variable not set")
process.exit(1)
}
try {
// Initialize context (sets up storage backends)
initializeContext(clineDir)
// Run migration
await runLegacySecretsMigrationIfNeeded()
console.log("MIGRATION_DONE")
process.exit(0)
} catch (error) {
console.error("MIGRATION_FAILED", error)
process.exit(1)
}
}
main()
@@ -0,0 +1,62 @@
import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import { expect } from "chai"
import sinon from "sinon"
import { secretStorage } from "@/core/storage/secrets"
import { __test_migrateFileSecretsToOS as migrate } from "@/standalone/vscode-context"
describe("Standalone secrets migration (unit)", () => {
const platform = os.platform()
if (platform !== "darwin") {
it("skipped on non-macOS", () => {
expect(true).to.equal(true)
})
return
}
let tmpDir: string
let secretsPath: string
let storeStub: sinon.SinonStub
let deleteStub: sinon.SinonStub
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cline-mig-unit-"))
secretsPath = path.join(tmpDir, "secrets.json")
fs.writeFileSync(secretsPath, JSON.stringify({ a: "1", b: "2" }, null, 2))
sinon.stub(secretStorage as any, "get").resolves(undefined)
storeStub = sinon.stub(secretStorage as any, "store").resolves()
deleteStub = sinon.stub(secretStorage as any, "delete").resolves()
})
afterEach(() => {
try {
fs.rmSync(tmpDir, { recursive: true, force: true })
} catch {}
sinon.restore()
})
it("migrates all entries and removes file", async () => {
await migrate(secretsPath)
expect(fs.existsSync(secretsPath)).to.equal(false)
expect(storeStub.callCount).to.equal(2)
expect(deleteStub.called).to.equal(false)
})
it("rolls back on failure and keeps file", async () => {
// Fail on second store
let count = 0
storeStub.callsFake(async () => {
count++
if (count === 2) {
throw new Error("simulated failure")
}
})
await migrate(secretsPath)
// rollback called for first key
expect(deleteStub.callCount).to.equal(1)
// legacy file preserved
expect(fs.existsSync(secretsPath)).to.equal(true)
})
})
+21
View File
@@ -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
}
}
+3
View File
@@ -24,3 +24,6 @@ vi.stubGlobal("acquireVsCodeApi", () => ({
getState: vi.fn(),
setState: vi.fn(),
}))
// Provide a test client ID to silence missing clientId errors in CI
vi.stubGlobal("clineClientId", "test-client")