mirror of
https://github.com/cline/cline.git
synced 2026-09-16 20:51:07 +08:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6001b7a9c2 | ||
|
|
2d44337542 | ||
|
|
f3e0337f73 | ||
|
|
052ff38e16 | ||
|
|
a5bdef041a | ||
|
|
5847032f82 | ||
|
|
3a0c74e265 | ||
|
|
f18fd1f4c7 | ||
|
|
cafe3d4702 | ||
|
|
479d139823 | ||
|
|
4ad9b02d8c | ||
|
|
11b94a802d | ||
|
|
f488ba3cbe | ||
|
|
84ec57b92d | ||
|
|
db14e41077 | ||
|
|
e0c81e094b | ||
|
|
78360bde4c | ||
|
|
5f8ee85596 | ||
|
|
9c5e33e705 | ||
|
|
75a7a3b0d9 | ||
|
|
409726b557 | ||
|
|
1c79eeca6b | ||
|
|
927284c314 | ||
|
|
9aef9f9a33 | ||
|
|
6ac3fdc43f | ||
|
|
c4f9a83bd7 | ||
|
|
ed86631eec | ||
|
|
9cdf09f3ee | ||
|
|
cf183463b7 | ||
|
|
5dc1163a56 | ||
|
|
44d142b21c | ||
|
|
3d9b45ba49 | ||
|
|
b2874bdfaf |
+89
-91
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 || "",
|
||||
],
|
||||
}),
|
||||
},
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
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
|
||||
@@ -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> {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user