Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
重构所有代码和文档中的项目名称,将OpenFang统一更新为ZCLAW。包括: - 配置文件中的项目名称 - 代码注释和文档引用 - 环境变量和路径 - 类型定义和接口名称 - 测试用例和模拟数据 同时优化部分代码结构,移除未使用的模块,并更新相关依赖项。
1311 lines
32 KiB
Markdown
1311 lines
32 KiB
Markdown
# ZCLAW 架构优化 - Phase 1: 安全 + 测试 实施计划
|
||
|
||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 建立安全基础和测试框架,为后续架构优化奠定基础
|
||
|
||
**Architecture:** 增强 `secure-storage.ts` 添加 AES-GCM 加密回退,增强 `gateway-client.ts` 强制 WSS,建立 Vitest 单元测试框架
|
||
|
||
**Tech Stack:** TypeScript, Vitest, Web Crypto API, AES-GCM
|
||
|
||
**Spec Reference:** `docs/superpowers/specs/2026-03-21-architecture-optimization-design.md`
|
||
|
||
**Duration:** 2 周 (8 人日)
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
### New Files
|
||
```
|
||
desktop/
|
||
├── src/
|
||
│ └── lib/
|
||
│ └── crypto-utils.ts # 加密工具函数 (新建)
|
||
├── tests/
|
||
│ ├── setup.ts # 测试设置文件 (新建)
|
||
│ ├── lib/
|
||
│ │ ├── secure-storage.test.ts # 安全存储测试 (新建)
|
||
│ │ ├── crypto-utils.test.ts # 加密工具测试 (新建)
|
||
│ │ └── gateway-security.test.ts # 网关安全测试 (新建)
|
||
│ └── store/
|
||
│ └── chatStore.test.ts # Chat Store 测试 (新建)
|
||
└── vitest.config.ts # Vitest 配置 (新建)
|
||
```
|
||
|
||
### Modified Files
|
||
```
|
||
desktop/
|
||
├── src/
|
||
│ └── lib/
|
||
│ ├── secure-storage.ts # 增强加密回退
|
||
│ └── gateway-client.ts # 强制 WSS
|
||
├── package.json # 添加依赖
|
||
└── tests/ # 现有测试目录
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 1: 测试框架建立
|
||
|
||
### Task 1.1: 安装测试依赖
|
||
|
||
**Files:**
|
||
- Modify: `desktop/package.json`
|
||
|
||
- [ ] **Step 1: 安装 Vitest 和测试相关依赖**
|
||
|
||
Run (Windows):
|
||
```bash
|
||
cd g:\ZClaw_zclaw\desktop && pnpm add -D vitest@2.1.8 @testing-library/react@16.1.0 @testing-library/jest-dom@6.6.3 jsdom@25.0.1
|
||
```
|
||
|
||
Run (Unix):
|
||
```bash
|
||
cd g:/ZClaw_zclaw/desktop && pnpm add -D vitest@2.1.8 @testing-library/react@16.1.0 @testing-library/jest-dom@6.6.3 jsdom@25.0.1
|
||
```
|
||
|
||
Expected: 依赖安装成功
|
||
|
||
- [ ] **Step 2: 验证安装**
|
||
|
||
Run:
|
||
```bash
|
||
cd g:\ZClaw_zclaw\desktop && pnpm exec vitest --version
|
||
```
|
||
|
||
Expected: 输出 `vitest/2.1.8`
|
||
|
||
---
|
||
|
||
### Task 1.2: 创建 Vitest 配置
|
||
|
||
**Files:**
|
||
- Create: `desktop/vitest.config.ts`
|
||
- Create: `desktop/tests/setup.ts`
|
||
|
||
- [ ] **Step 1: 创建 Vitest 配置文件**
|
||
|
||
Create `desktop/vitest.config.ts`:
|
||
|
||
```typescript
|
||
import { defineConfig } from 'vitest/config';
|
||
import react from '@vitejs/plugin-react';
|
||
import path from 'path';
|
||
|
||
export default defineConfig({
|
||
plugins: [react()],
|
||
test: {
|
||
globals: true,
|
||
environment: 'jsdom',
|
||
setupFiles: ['./tests/setup.ts'],
|
||
include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'],
|
||
exclude: ['tests/e2e/**', 'node_modules/**'],
|
||
coverage: {
|
||
provider: 'v8',
|
||
reporter: ['text', 'json', 'html'],
|
||
exclude: [
|
||
'node_modules/**',
|
||
'tests/**',
|
||
'**/*.d.ts',
|
||
'**/*.config.*',
|
||
'src/main.tsx',
|
||
],
|
||
thresholds: {
|
||
lines: 60,
|
||
functions: 60,
|
||
branches: 60,
|
||
statements: 60,
|
||
},
|
||
},
|
||
},
|
||
resolve: {
|
||
alias: {
|
||
'@': path.resolve(__dirname, './src'),
|
||
},
|
||
},
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 创建测试设置文件**
|
||
|
||
Create `desktop/tests/setup.ts`:
|
||
|
||
```typescript
|
||
import '@testing-library/jest-dom';
|
||
import { vi } from 'vitest';
|
||
import { webcrypto } from 'node:crypto';
|
||
|
||
// Polyfill Web Crypto API for Node.js test environment
|
||
Object.defineProperty(global, 'crypto', {
|
||
value: webcrypto,
|
||
});
|
||
|
||
// Mock Tauri API
|
||
vi.mock('@tauri-apps/api/core', () => ({
|
||
invoke: vi.fn(),
|
||
}));
|
||
|
||
// Mock Tauri runtime check - use alias path for consistency
|
||
vi.mock('@/lib/tauri-gateway', () => ({
|
||
isTauriRuntime: () => false,
|
||
getGatewayClient: vi.fn(),
|
||
}));
|
||
|
||
// Mock localStorage
|
||
const localStorageMock = (() => {
|
||
let store: Record<string, string> = {};
|
||
return {
|
||
getItem: (key: string) => store[key] || null,
|
||
setItem: (key: string, value: string) => {
|
||
store[key] = value;
|
||
},
|
||
removeItem: (key: string) => {
|
||
delete store[key];
|
||
},
|
||
clear: () => {
|
||
store = {};
|
||
},
|
||
};
|
||
})();
|
||
|
||
Object.defineProperty(global, 'localStorage', {
|
||
value: localStorageMock,
|
||
});
|
||
|
||
// Mock crypto.subtle for tests
|
||
const cryptoMock = {
|
||
subtle: {
|
||
encrypt: vi.fn(),
|
||
decrypt: vi.fn(),
|
||
generateKey: vi.fn(),
|
||
deriveKey: vi.fn(),
|
||
importKey: vi.fn(),
|
||
exportKey: vi.fn(),
|
||
},
|
||
getRandomValues: (array: Uint8Array) => {
|
||
for (let i = 0; i < array.length; i++) {
|
||
array[i] = Math.floor(Math.random() * 256);
|
||
}
|
||
return array;
|
||
},
|
||
};
|
||
|
||
Object.defineProperty(global, 'crypto', {
|
||
value: cryptoMock,
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: 更新 package.json 测试脚本**
|
||
|
||
Verify `desktop/package.json` scripts section includes:
|
||
|
||
```json
|
||
{
|
||
"scripts": {
|
||
"test": "vitest run",
|
||
"test:watch": "vitest",
|
||
"test:coverage": "vitest run --coverage"
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 运行测试验证配置**
|
||
|
||
Run:
|
||
```bash
|
||
cd g:/ZClaw_zclaw/desktop && pnpm test
|
||
```
|
||
|
||
Expected: 测试运行成功 (可能显示 0 tests,这是正常的)
|
||
|
||
- [ ] **Step 5: 提交测试框架配置**
|
||
|
||
```bash
|
||
cd g:/ZClaw_zclaw && git add desktop/vitest.config.ts desktop/tests/setup.ts desktop/package.json
|
||
git commit -m "test: add Vitest configuration and setup
|
||
|
||
- Add vitest.config.ts with coverage thresholds
|
||
- Add tests/setup.ts with mocks for Tauri and crypto
|
||
- Update package.json with test dependencies
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 2: 加密工具模块
|
||
|
||
### Task 2.1: 创建加密工具函数
|
||
|
||
**Files:**
|
||
- Create: `desktop/src/lib/crypto-utils.ts`
|
||
- Create: `desktop/tests/lib/crypto-utils.test.ts`
|
||
|
||
- [ ] **Step 1: 写失败的测试 - arrayToBase64**
|
||
|
||
Create `desktop/tests/lib/crypto-utils.test.ts`:
|
||
|
||
```typescript
|
||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||
import { arrayToBase64, base64ToArray, deriveKey, encrypt, decrypt } from '../../src/lib/crypto-utils';
|
||
|
||
describe('crypto-utils', () => {
|
||
describe('arrayToBase64', () => {
|
||
it('should convert Uint8Array to base64 string', () => {
|
||
const arr = new Uint8Array([72, 101, 108, 108, 111]); // "Hello"
|
||
const result = arrayToBase64(arr);
|
||
expect(result).toBe('SGVsbG8=');
|
||
});
|
||
|
||
it('should handle empty array', () => {
|
||
const arr = new Uint8Array([]);
|
||
const result = arrayToBase64(arr);
|
||
expect(result).toBe('');
|
||
});
|
||
});
|
||
|
||
describe('base64ToArray', () => {
|
||
it('should convert base64 string to Uint8Array', () => {
|
||
const base64 = 'SGVsbG8=';
|
||
const result = base64ToArray(base64);
|
||
expect(result).toEqual(new Uint8Array([72, 101, 108, 108, 111]));
|
||
});
|
||
|
||
it('should handle empty string', () => {
|
||
const result = base64ToArray('');
|
||
expect(result).toEqual(new Uint8Array([]));
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: 运行测试确认失败**
|
||
|
||
Run:
|
||
```bash
|
||
cd g:/ZClaw_zclaw/desktop && pnpm test tests/lib/crypto-utils.test.ts
|
||
```
|
||
|
||
Expected: FAIL - "Cannot find module '../../src/lib/crypto-utils'"
|
||
|
||
- [ ] **Step 3: 实现加密工具函数**
|
||
|
||
Create `desktop/src/lib/crypto-utils.ts`:
|
||
|
||
```typescript
|
||
/**
|
||
* Cryptographic utilities for secure storage
|
||
* Uses Web Crypto API for AES-GCM encryption
|
||
*/
|
||
|
||
const SALT = new TextEncoder().encode('zclaw-secure-storage-salt');
|
||
const ITERATIONS = 100000;
|
||
|
||
/**
|
||
* Convert Uint8Array to base64 string
|
||
*/
|
||
export function arrayToBase64(array: Uint8Array): string {
|
||
if (array.length === 0) return '';
|
||
|
||
let binary = '';
|
||
for (let i = 0; i < array.length; i++) {
|
||
binary += String.fromCharCode(array[i]);
|
||
}
|
||
return btoa(binary);
|
||
}
|
||
|
||
/**
|
||
* Convert base64 string to Uint8Array
|
||
*/
|
||
export function base64ToArray(base64: string): Uint8Array {
|
||
if (!base64) return new Uint8Array([]);
|
||
|
||
const binary = atob(base64);
|
||
const array = new Uint8Array(binary.length);
|
||
for (let i = 0; i < binary.length; i++) {
|
||
array[i] = binary.charCodeAt(i);
|
||
}
|
||
return array;
|
||
}
|
||
|
||
/**
|
||
* Derive an encryption key from a master key
|
||
*/
|
||
export async function deriveKey(
|
||
masterKey: string,
|
||
salt: Uint8Array = SALT
|
||
): Promise<CryptoKey> {
|
||
const encoder = new TextEncoder();
|
||
const keyMaterial = await crypto.subtle.importKey(
|
||
'raw',
|
||
encoder.encode(masterKey),
|
||
'PBKDF2',
|
||
false,
|
||
['deriveBits', 'deriveKey']
|
||
);
|
||
|
||
return crypto.subtle.deriveKey(
|
||
{
|
||
name: 'PBKDF2',
|
||
salt,
|
||
iterations: ITERATIONS,
|
||
hash: 'SHA-256',
|
||
},
|
||
keyMaterial,
|
||
{ name: 'AES-GCM', length: 256 },
|
||
false,
|
||
['encrypt', 'decrypt']
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Encrypt data using AES-GCM
|
||
*/
|
||
export async function encrypt(
|
||
plaintext: string,
|
||
key: CryptoKey
|
||
): Promise<{ iv: string; data: string }> {
|
||
const encoder = new TextEncoder();
|
||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||
|
||
const encrypted = await crypto.subtle.encrypt(
|
||
{ name: 'AES-GCM', iv },
|
||
key,
|
||
encoder.encode(plaintext)
|
||
);
|
||
|
||
return {
|
||
iv: arrayToBase64(iv),
|
||
data: arrayToBase64(new Uint8Array(encrypted)),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Decrypt data using AES-GCM
|
||
*/
|
||
export async function decrypt(
|
||
encrypted: { iv: string; data: string },
|
||
key: CryptoKey
|
||
): Promise<string> {
|
||
const decoder = new TextDecoder();
|
||
const decrypted = await crypto.subtle.decrypt(
|
||
{ name: 'AES-GCM', iv: base64ToArray(encrypted.iv) },
|
||
key,
|
||
base64ToArray(encrypted.data)
|
||
);
|
||
|
||
return decoder.decode(decrypted);
|
||
}
|
||
|
||
/**
|
||
* Generate a random master key for encryption
|
||
*/
|
||
export function generateMasterKey(): string {
|
||
const array = crypto.getRandomValues(new Uint8Array(32));
|
||
return arrayToBase64(array);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: 运行测试确认通过**
|
||
|
||
Run:
|
||
```bash
|
||
cd g:/ZClaw_zclaw/desktop && pnpm test tests/lib/crypto-utils.test.ts
|
||
```
|
||
|
||
Expected: PASS - 2 tests
|
||
|
||
- [ ] **Step 5: 写失败的测试 - encrypt/decrypt**
|
||
|
||
Add to `desktop/tests/lib/crypto-utils.test.ts`:
|
||
|
||
```typescript
|
||
describe('encrypt and decrypt', () => {
|
||
it('should encrypt and decrypt text correctly', async () => {
|
||
// Mock crypto.subtle for Node.js environment
|
||
const originalCrypto = global.crypto;
|
||
|
||
// Use a simple mock that actually works
|
||
const key = await crypto.subtle.generateKey(
|
||
{ name: 'AES-GCM', length: 256 },
|
||
true,
|
||
['encrypt', 'decrypt']
|
||
);
|
||
|
||
const plaintext = 'secret message';
|
||
const encrypted = await encrypt(plaintext, key);
|
||
|
||
expect(encrypted.iv).toBeDefined();
|
||
expect(encrypted.data).toBeDefined();
|
||
expect(encrypted.data).not.toBe(plaintext);
|
||
|
||
const decrypted = await decrypt(encrypted, key);
|
||
expect(decrypted).toBe(plaintext);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6: 运行测试**
|
||
|
||
Run:
|
||
```bash
|
||
cd g:/ZClaw_zclaw/desktop && pnpm test tests/lib/crypto-utils.test.ts
|
||
```
|
||
|
||
Expected: PASS - 3 tests
|
||
|
||
- [ ] **Step 7: 提交加密工具模块**
|
||
|
||
```bash
|
||
cd g:/ZClaw_zclaw && git add desktop/src/lib/crypto-utils.ts desktop/tests/lib/crypto-utils.test.ts
|
||
git commit -m "feat(crypto): add AES-GCM encryption utilities
|
||
|
||
- Add arrayToBase64/base64ToArray conversion functions
|
||
- Add deriveKey for PBKDF2 key derivation
|
||
- Add encrypt/decrypt using AES-GCM
|
||
- Add unit tests for all functions
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 3: 凭据存储加密增强
|
||
|
||
### Task 3.1: 增强安全存储
|
||
|
||
**Files:**
|
||
- Modify: `desktop/src/lib/secure-storage.ts`
|
||
- Create: `desktop/tests/lib/secure-storage.test.ts`
|
||
|
||
- [ ] **Step 1: 读取现有 secure-storage.ts**
|
||
|
||
Read the full file to understand current implementation:
|
||
- File: `desktop/src/lib/secure-storage.ts`
|
||
|
||
- [ ] **Step 2: 写失败的测试 - 加密回退**
|
||
|
||
Create `desktop/tests/lib/secure-storage.test.ts`:
|
||
|
||
```typescript
|
||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||
import { secureStorage, isSecureStorageAvailable } from '../../src/lib/secure-storage';
|
||
|
||
// Mock Tauri runtime to return false (non-Tauri environment)
|
||
vi.mock('../../src/lib/tauri-gateway', () => ({
|
||
isTauriRuntime: () => false,
|
||
}));
|
||
|
||
describe('secureStorage', () => {
|
||
beforeEach(() => {
|
||
localStorage.clear();
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
describe('encryption fallback', () => {
|
||
it('should encrypt data when storing to localStorage', async () => {
|
||
const key = 'test-key';
|
||
const value = 'secret-value';
|
||
|
||
await secureStorage.set(key, value);
|
||
|
||
// Check that localStorage doesn't contain plaintext
|
||
const stored = localStorage.getItem(key);
|
||
expect(stored).not.toBe(value);
|
||
expect(stored).not.toContain('secret-value');
|
||
});
|
||
|
||
it('should decrypt data when retrieving from localStorage', async () => {
|
||
const key = 'test-key';
|
||
const value = 'secret-value';
|
||
|
||
await secureStorage.set(key, value);
|
||
const retrieved = await secureStorage.get(key);
|
||
|
||
expect(retrieved).toBe(value);
|
||
});
|
||
|
||
it('should handle special characters in values', async () => {
|
||
const key = 'test-key';
|
||
const value = 'p@ssw0rd!#$%^&*(){}[]|\\:";\'<>?,./~`';
|
||
|
||
await secureStorage.set(key, value);
|
||
const retrieved = await secureStorage.get(key);
|
||
|
||
expect(retrieved).toBe(value);
|
||
});
|
||
});
|
||
|
||
describe('backward compatibility', () => {
|
||
it('should read unencrypted legacy data', async () => {
|
||
const key = 'legacy-key';
|
||
const value = 'legacy-value';
|
||
|
||
// Simulate legacy unencrypted storage
|
||
localStorage.setItem(key, value);
|
||
|
||
const retrieved = await secureStorage.get(key);
|
||
expect(retrieved).toBe(value);
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: 运行测试确认失败**
|
||
|
||
Run:
|
||
```bash
|
||
cd g:/ZClaw_zclaw/desktop && pnpm test tests/lib/secure-storage.test.ts
|
||
```
|
||
|
||
Expected: FAIL - localStorage 存储明文
|
||
|
||
- [ ] **Step 4: 实现加密回退**
|
||
|
||
Modify `desktop/src/lib/secure-storage.ts`, add import and enhance the fallback functions:
|
||
|
||
```typescript
|
||
// Add to imports
|
||
import { arrayToBase64, base64ToArray, deriveKey, encrypt, decrypt, generateMasterKey } from './crypto-utils';
|
||
|
||
// Add constant for encrypted key prefix
|
||
const ENCRYPTED_PREFIX = 'enc_';
|
||
const MASTER_KEY_NAME = 'zclaw-master-key';
|
||
|
||
// Add helper functions after the existing code
|
||
|
||
/**
|
||
* Get or create master key for encryption
|
||
*/
|
||
async function getOrCreateMasterKey(): Promise<CryptoKey> {
|
||
let masterKeyRaw = localStorage.getItem(MASTER_KEY_NAME);
|
||
|
||
if (!masterKeyRaw) {
|
||
masterKeyRaw = generateMasterKey();
|
||
localStorage.setItem(MASTER_KEY_NAME, masterKeyRaw);
|
||
}
|
||
|
||
return deriveKey(masterKeyRaw);
|
||
}
|
||
|
||
/**
|
||
* Check if a stored value is encrypted
|
||
*/
|
||
function isEncrypted(value: string): boolean {
|
||
try {
|
||
const parsed = JSON.parse(value);
|
||
return parsed && typeof parsed.iv === 'string' && typeof parsed.data === 'string';
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Write encrypted data to localStorage
|
||
*/
|
||
async function writeEncryptedLocalStorage(key: string, value: string): Promise<void> {
|
||
if (!value) {
|
||
localStorage.removeItem(ENCRYPTED_PREFIX + key);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const cryptoKey = await getOrCreateMasterKey();
|
||
const encrypted = await encrypt(value, cryptoKey);
|
||
localStorage.setItem(ENCRYPTED_PREFIX + key, JSON.stringify(encrypted));
|
||
} catch (error) {
|
||
console.error('[SecureStorage] Encryption failed:', error);
|
||
// Fallback to plaintext if encryption fails
|
||
localStorage.setItem(key, value);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Read encrypted data from localStorage
|
||
*/
|
||
async function readEncryptedLocalStorage(key: string): Promise<string | null> {
|
||
// Try encrypted version first
|
||
const encryptedRaw = localStorage.getItem(ENCRYPTED_PREFIX + key);
|
||
|
||
if (encryptedRaw && isEncrypted(encryptedRaw)) {
|
||
try {
|
||
const cryptoKey = await getOrCreateMasterKey();
|
||
const encrypted = JSON.parse(encryptedRaw);
|
||
return await decrypt(encrypted, cryptoKey);
|
||
} catch (error) {
|
||
console.error('[SecureStorage] Decryption failed:', error);
|
||
}
|
||
}
|
||
|
||
// Fallback to unencrypted version for backward compatibility
|
||
return localStorage.getItem(key);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 更新 secureStorage 接口使用加密函数**
|
||
|
||
Modify the `secureStorage` object to use encrypted localStorage:
|
||
|
||
```typescript
|
||
export const secureStorage = {
|
||
async set(key: string, value: string): Promise<void> {
|
||
const trimmedValue = value.trim();
|
||
|
||
if (await isSecureStorageAvailable()) {
|
||
try {
|
||
if (trimmedValue) {
|
||
await invoke('secure_store_set', { key, value: trimmedValue });
|
||
} else {
|
||
await invoke('secure_store_delete', { key });
|
||
}
|
||
// Also write encrypted backup
|
||
await writeEncryptedLocalStorage(key, trimmedValue);
|
||
return;
|
||
} catch (error) {
|
||
console.warn('[SecureStorage] Failed to use keyring, falling back to encrypted localStorage:', error);
|
||
}
|
||
}
|
||
|
||
// Fallback to encrypted localStorage
|
||
await writeEncryptedLocalStorage(key, trimmedValue);
|
||
},
|
||
|
||
async get(key: string): Promise<string | null> {
|
||
if (await isSecureStorageAvailable()) {
|
||
try {
|
||
const value = await invoke<string>('secure_store_get', { key });
|
||
if (value !== null && value !== undefined && value !== '') {
|
||
return value;
|
||
}
|
||
// Try encrypted fallback for migration
|
||
return await readEncryptedLocalStorage(key);
|
||
} catch (error) {
|
||
console.warn('[SecureStorage] Failed to read from keyring, trying encrypted localStorage:', error);
|
||
}
|
||
}
|
||
|
||
// Fallback to encrypted localStorage
|
||
return await readEncryptedLocalStorage(key);
|
||
},
|
||
|
||
// ... rest of the methods remain similar
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 6: 运行测试确认通过**
|
||
|
||
Run:
|
||
```bash
|
||
cd g:/ZClaw_zclaw/desktop && pnpm test tests/lib/secure-storage.test.ts
|
||
```
|
||
|
||
Expected: PASS - 4 tests
|
||
|
||
- [ ] **Step 7: 提交凭据加密增强**
|
||
|
||
```bash
|
||
cd g:/ZClaw_zclaw && git add desktop/src/lib/secure-storage.ts desktop/tests/lib/secure-storage.test.ts
|
||
git commit -m "feat(security): add AES-GCM encryption for localStorage fallback
|
||
|
||
- Encrypt credentials before storing in localStorage
|
||
- Decrypt on retrieval with automatic fallback
|
||
- Backward compatible with existing unencrypted data
|
||
- Add comprehensive unit tests
|
||
|
||
Security: Credentials are now encrypted using AES-GCM when
|
||
OS keyring is unavailable, preventing plaintext exposure.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 4: 强制 WSS 连接
|
||
|
||
### Task 4.1: 增强网关安全
|
||
|
||
**Files:**
|
||
- Modify: `desktop/src/lib/gateway-client.ts`
|
||
- Create: `desktop/tests/lib/gateway-security.test.ts`
|
||
|
||
**前置说明:**
|
||
- `isLocalhost` 函数已存在于 `gateway-storage.ts` (第 27-36 行)
|
||
- `gateway-client.ts` 已导入 `isLocalhost` (第 41 行)
|
||
- 现有代码仅输出警告 (第 209 行),需要改为抛出错误
|
||
|
||
- [ ] **Step 1: 读取现有 gateway-client.ts WebSocket 连接逻辑**
|
||
|
||
Use Read tool to view `desktop/src/lib/gateway-client.ts` lines 200-220 to see current implementation.
|
||
|
||
- [ ] **Step 2: 写失败的测试 - WSS 强制**
|
||
|
||
Create `desktop/tests/lib/gateway-security.test.ts`:
|
||
|
||
```typescript
|
||
import { describe, it, expect, vi } from 'vitest';
|
||
|
||
// Mock WebSocket
|
||
class MockWebSocket {
|
||
url: string;
|
||
readyState: number = WebSocket.CONNECTING;
|
||
onopen: (() => void) | null = null;
|
||
onerror: ((error: Error) => void) | null = null;
|
||
|
||
constructor(url: string) {
|
||
this.url = url;
|
||
setTimeout(() => {
|
||
if (this.onopen) this.onopen();
|
||
}, 0);
|
||
}
|
||
|
||
close() {
|
||
this.readyState = WebSocket.CLOSED;
|
||
}
|
||
}
|
||
|
||
vi.stubGlobal('WebSocket', MockWebSocket);
|
||
|
||
describe('WebSocket Security', () => {
|
||
describe('isLocalhost', () => {
|
||
it('should identify localhost URLs', () => {
|
||
const localhostUrls = [
|
||
'ws://localhost:4200',
|
||
'ws://127.0.0.1:4200',
|
||
'ws://[::1]:4200',
|
||
'wss://localhost:4200',
|
||
];
|
||
|
||
localhostUrls.forEach(url => {
|
||
const parsed = new URL(url);
|
||
const isLocal = ['localhost', '127.0.0.1', '::1'].includes(parsed.hostname);
|
||
expect(isLocal).toBe(true);
|
||
});
|
||
});
|
||
|
||
it('should identify non-localhost URLs', () => {
|
||
const remoteUrls = [
|
||
'ws://example.com:4200',
|
||
'ws://192.168.1.1:4200',
|
||
'wss://api.example.com/ws',
|
||
];
|
||
|
||
remoteUrls.forEach(url => {
|
||
const parsed = new URL(url);
|
||
const isLocal = ['localhost', '127.0.0.1', '::1'].includes(parsed.hostname);
|
||
expect(isLocal).toBe(false);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('WSS enforcement', () => {
|
||
it('should allow ws:// for localhost', () => {
|
||
const url = 'ws://localhost:4200';
|
||
const parsed = new URL(url);
|
||
const isSecure = url.startsWith('wss://') ||
|
||
['localhost', '127.0.0.1', '::1'].includes(parsed.hostname);
|
||
|
||
expect(isSecure).toBe(true);
|
||
});
|
||
|
||
it('should reject ws:// for non-localhost', () => {
|
||
const url = 'ws://example.com:4200';
|
||
const parsed = new URL(url);
|
||
const isSecure = url.startsWith('wss://') ||
|
||
['localhost', '127.0.0.1', '::1'].includes(parsed.hostname);
|
||
|
||
expect(isSecure).toBe(false);
|
||
});
|
||
|
||
it('should allow wss:// for any host', () => {
|
||
const urls = [
|
||
'wss://example.com:4200',
|
||
'wss://api.example.com/ws',
|
||
'wss://192.168.1.1:4200',
|
||
];
|
||
|
||
urls.forEach(url => {
|
||
expect(url.startsWith('wss://')).toBe(true);
|
||
});
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: 运行测试确认通过 (测试逻辑)**
|
||
|
||
Run:
|
||
```bash
|
||
cd g:/ZClaw_zclaw/desktop && pnpm test tests/lib/gateway-security.test.ts
|
||
```
|
||
|
||
Expected: PASS - 6 tests (这些是纯逻辑测试)
|
||
|
||
- [ ] **Step 4: 在 gateway-client.ts 中添加安全检查**
|
||
|
||
Add helper function and modify connection logic in `desktop/src/lib/gateway-client.ts`:
|
||
|
||
```typescript
|
||
// Add near the top of the file, after imports
|
||
|
||
/**
|
||
* Check if URL points to localhost
|
||
*/
|
||
function isLocalhost(url: string): boolean {
|
||
try {
|
||
const parsed = new URL(url);
|
||
return ['localhost', '127.0.0.1', '::1'].includes(parsed.hostname);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Validate WebSocket URL security
|
||
* @throws SecurityError if non-localhost URL uses ws:// instead of wss://
|
||
*/
|
||
function validateWebSocketSecurity(url: string): void {
|
||
if (!url.startsWith('wss://') && !isLocalhost(url)) {
|
||
throw new SecurityError(
|
||
'Non-localhost connections must use WSS protocol for security. ' +
|
||
`URL: ${url.replace(/:[^:@]+@/, ':****@')}`
|
||
);
|
||
}
|
||
}
|
||
|
||
// Add SecurityError class if not already present
|
||
class SecurityError extends Error {
|
||
constructor(message: string) {
|
||
super(message);
|
||
this.name = 'SecurityError';
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: 在 connect 方法中调用安全验证**
|
||
|
||
Find the `connect` method and add the validation call:
|
||
|
||
```typescript
|
||
async connect(): Promise<void> {
|
||
// Add security validation at the start
|
||
try {
|
||
validateWebSocketSecurity(this.url);
|
||
} catch (error) {
|
||
this.log('error', `Security validation failed: ${error}`);
|
||
throw error;
|
||
}
|
||
|
||
// ... rest of existing connect logic
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: 运行现有测试确保无回归**
|
||
|
||
Run:
|
||
```bash
|
||
cd g:/ZClaw_zclaw/desktop && pnpm test
|
||
```
|
||
|
||
Expected: All tests pass
|
||
|
||
- [ ] **Step 7: 提交 WSS 强制策略**
|
||
|
||
```bash
|
||
cd g:/ZClaw_zclaw && git add desktop/src/lib/gateway-client.ts desktop/tests/lib/gateway-security.test.ts
|
||
git commit -m "feat(security): enforce WSS for non-localhost connections
|
||
|
||
- Add validateWebSocketSecurity function
|
||
- Block ws:// connections to non-localhost hosts
|
||
- Add SecurityError class for clear error handling
|
||
- Add unit tests for security validation logic
|
||
|
||
Security: Prevents man-in-the-middle attacks on remote connections
|
||
by requiring WSS protocol for all non-localhost WebSocket connections.
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 5: ChatStore 基础测试
|
||
|
||
### Task 5.1: 创建 ChatStore 测试套件
|
||
|
||
**Files:**
|
||
- Create: `desktop/tests/store/chatStore.test.ts`
|
||
|
||
- [ ] **Step 1: 读取 chatStore.ts 了解 API**
|
||
|
||
Run:
|
||
```bash
|
||
head -n 100 g:/ZClaw_zclaw/desktop/src/store/chatStore.ts
|
||
```
|
||
|
||
- [ ] **Step 2: 写失败的测试 - 基础功能**
|
||
|
||
Create `desktop/tests/store/chatStore.test.ts`:
|
||
|
||
```typescript
|
||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||
import { useChatStore } from '../../src/store/chatStore';
|
||
|
||
// Mock dependencies
|
||
vi.mock('../../src/lib/gateway-client', () => ({
|
||
getGatewayClient: vi.fn(() => ({
|
||
chatStream: vi.fn(),
|
||
chat: vi.fn(),
|
||
onAgentStream: vi.fn(() => () => {}),
|
||
})),
|
||
}));
|
||
|
||
vi.mock('../../src/lib/intelligence-client', () => ({
|
||
intelligenceClient: {
|
||
compactor: {
|
||
checkThreshold: vi.fn(() => Promise.resolve({ needsCompaction: false })),
|
||
},
|
||
memory: {
|
||
search: vi.fn(() => Promise.resolve([])),
|
||
},
|
||
reflection: {
|
||
shouldReflect: vi.fn(() => Promise.resolve(false)),
|
||
},
|
||
},
|
||
}));
|
||
|
||
vi.mock('../../src/lib/memory-extractor', () => ({
|
||
getMemoryExtractor: vi.fn(() => ({
|
||
extract: vi.fn(() => Promise.resolve([])),
|
||
})),
|
||
}));
|
||
|
||
describe('chatStore', () => {
|
||
beforeEach(() => {
|
||
// Reset store state before each test
|
||
useChatStore.setState({
|
||
messages: [],
|
||
conversations: [],
|
||
currentConversationId: null,
|
||
isStreaming: false,
|
||
error: null,
|
||
});
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
describe('initial state', () => {
|
||
it('should have empty messages array', () => {
|
||
const state = useChatStore.getState();
|
||
expect(state.messages).toEqual([]);
|
||
});
|
||
|
||
it('should not be streaming initially', () => {
|
||
const state = useChatStore.getState();
|
||
expect(state.isStreaming).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('addMessage', () => {
|
||
it('should add a message to the store', () => {
|
||
const { addMessage } = useChatStore.getState();
|
||
const message = {
|
||
id: 'test-1',
|
||
role: 'user' as const,
|
||
content: 'Hello',
|
||
timestamp: Date.now(),
|
||
};
|
||
|
||
addMessage(message);
|
||
|
||
const state = useChatStore.getState();
|
||
expect(state.messages).toHaveLength(1);
|
||
expect(state.messages[0]).toEqual(message);
|
||
});
|
||
|
||
it('should append message to existing messages', () => {
|
||
const { addMessage } = useChatStore.getState();
|
||
|
||
addMessage({
|
||
id: 'test-1',
|
||
role: 'user',
|
||
content: 'First',
|
||
timestamp: Date.now(),
|
||
});
|
||
|
||
addMessage({
|
||
id: 'test-2',
|
||
role: 'assistant',
|
||
content: 'Second',
|
||
timestamp: Date.now(),
|
||
});
|
||
|
||
const state = useChatStore.getState();
|
||
expect(state.messages).toHaveLength(2);
|
||
});
|
||
});
|
||
|
||
describe('setStreaming', () => {
|
||
it('should update streaming state', () => {
|
||
const { setStreaming } = useChatStore.getState();
|
||
|
||
setStreaming(true);
|
||
expect(useChatStore.getState().isStreaming).toBe(true);
|
||
|
||
setStreaming(false);
|
||
expect(useChatStore.getState().isStreaming).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('clearMessages', () => {
|
||
it('should clear all messages', () => {
|
||
const { addMessage, clearMessages } = useChatStore.getState();
|
||
|
||
addMessage({
|
||
id: 'test-1',
|
||
role: 'user',
|
||
content: 'Test',
|
||
timestamp: Date.now(),
|
||
});
|
||
|
||
clearMessages();
|
||
|
||
expect(useChatStore.getState().messages).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('error handling', () => {
|
||
it('should store error state', () => {
|
||
const { setError } = useChatStore.getState();
|
||
const error = new Error('Test error');
|
||
|
||
setError(error);
|
||
|
||
expect(useChatStore.getState().error).toBe(error);
|
||
});
|
||
|
||
it('should clear error with null', () => {
|
||
const { setError } = useChatStore.getState();
|
||
|
||
setError(new Error('Test'));
|
||
setError(null);
|
||
|
||
expect(useChatStore.getState().error).toBeNull();
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: 运行测试**
|
||
|
||
Run:
|
||
```bash
|
||
cd g:/ZClaw_zclaw/desktop && pnpm test tests/store/chatStore.test.ts
|
||
```
|
||
|
||
Expected: PASS - 8+ tests
|
||
|
||
- [ ] **Step 4: 添加更多测试 - 消息更新**
|
||
|
||
Add to `desktop/tests/store/chatStore.test.ts`:
|
||
|
||
```typescript
|
||
describe('updateMessage', () => {
|
||
it('should update existing message content', () => {
|
||
const { addMessage, updateMessage } = useChatStore.getState();
|
||
|
||
addMessage({
|
||
id: 'test-1',
|
||
role: 'assistant',
|
||
content: 'Initial',
|
||
timestamp: Date.now(),
|
||
});
|
||
|
||
updateMessage('test-1', { content: 'Updated' });
|
||
|
||
const state = useChatStore.getState();
|
||
expect(state.messages[0].content).toBe('Updated');
|
||
});
|
||
|
||
it('should not modify message if id not found', () => {
|
||
const { addMessage, updateMessage } = useChatStore.getState();
|
||
|
||
addMessage({
|
||
id: 'test-1',
|
||
role: 'user',
|
||
content: 'Test',
|
||
timestamp: Date.now(),
|
||
});
|
||
|
||
updateMessage('non-existent', { content: 'Should not appear' });
|
||
|
||
const state = useChatStore.getState();
|
||
expect(state.messages[0].content).toBe('Test');
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 5: 运行测试确认通过**
|
||
|
||
Run:
|
||
```bash
|
||
cd g:/ZClaw_zclaw/desktop && pnpm test tests/store/chatStore.test.ts
|
||
```
|
||
|
||
Expected: PASS - 10+ tests
|
||
|
||
- [ ] **Step 6: 运行覆盖率报告**
|
||
|
||
Run:
|
||
```bash
|
||
cd g:/ZClaw_zclaw/desktop && pnpm test:coverage tests/store/chatStore.test.ts
|
||
```
|
||
|
||
Expected: Coverage > 80% for chatStore
|
||
|
||
- [ ] **Step 7: 提交 ChatStore 测试**
|
||
|
||
```bash
|
||
cd g:/ZClaw_zclaw && git add desktop/tests/store/chatStore.test.ts
|
||
git commit -m "test(chat): add comprehensive unit tests for chatStore
|
||
|
||
- Test initial state
|
||
- Test addMessage functionality
|
||
- Test setStreaming state management
|
||
- Test clearMessages functionality
|
||
- Test error handling
|
||
- Test updateMessage functionality
|
||
|
||
Coverage: 80%+ for chatStore module
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
## Chunk 6: 集成与验收
|
||
|
||
### Task 6.1: 运行完整测试套件
|
||
|
||
- [ ] **Step 1: 运行所有测试**
|
||
|
||
Run:
|
||
```bash
|
||
cd g:/ZClaw_zclaw/desktop && pnpm test
|
||
```
|
||
|
||
Expected: All tests pass
|
||
|
||
- [ ] **Step 2: 生成覆盖率报告**
|
||
|
||
Run:
|
||
```bash
|
||
cd g:/ZClaw_zclaw/desktop && pnpm test:coverage
|
||
```
|
||
|
||
Expected: Overall coverage >= 60%
|
||
|
||
- [ ] **Step 3: 验证覆盖率门禁**
|
||
|
||
Check that coverage thresholds in `vitest.config.ts` are being enforced.
|
||
|
||
### Task 6.2: 手动验证安全功能
|
||
|
||
- [ ] **Step 1: 验证加密存储**
|
||
|
||
1. 启动应用: `pnpm tauri:dev`
|
||
2. 在开发者工具中检查 localStorage
|
||
3. 确认凭据值是加密的 (包含 `iv` 和 `data` 字段)
|
||
|
||
- [ ] **Step 2: 验证 WSS 强制**
|
||
|
||
1. 尝试连接 `ws://example.com:4200`
|
||
2. 确认连接被拒绝并显示安全错误
|
||
3. 确认 `ws://localhost:4200` 仍然可以连接
|
||
|
||
### Task 6.3: 更新文档
|
||
|
||
- [ ] **Step 1: 更新 README 或创建变更日志**
|
||
|
||
Create `docs/changelogs/2026-03-21-phase1-security.md`:
|
||
|
||
```markdown
|
||
# Phase 1: Security + Testing
|
||
|
||
## Changes
|
||
|
||
### Security
|
||
- **凭据加密**: localStorage 回退现在使用 AES-GCM 加密
|
||
- **WSS 强制**: 非 localhost 连接必须使用 WSS 协议
|
||
|
||
### Testing
|
||
- 建立 Vitest 测试框架
|
||
- 添加 chatStore 单元测试 (80%+ 覆盖率)
|
||
- 添加加密工具测试
|
||
- 添加网关安全测试
|
||
|
||
## Migration Notes
|
||
- 现有未加密的 localStorage 数据会在首次读取时自动迁移
|
||
- 无需手动干预
|
||
|
||
## Breaking Changes
|
||
- `ws://` 协议不再允许用于非 localhost 连接
|
||
- 如需使用远程服务器,请确保使用 `wss://`
|
||
```
|
||
|
||
- [ ] **Step 2: 提交文档更新**
|
||
|
||
```bash
|
||
cd g:/ZClaw_zclaw && git add docs/changelogs/2026-03-21-phase1-security.md
|
||
git commit -m "docs: add Phase 1 changelog
|
||
|
||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
|
||
```
|
||
|
||
### Task 6.4: 最终提交和标记
|
||
|
||
- [ ] **Step 1: 创建 Phase 1 完成标签**
|
||
|
||
```bash
|
||
cd g:/ZClaw_zclaw && git tag -a v0.3.0-phase1 -m "Phase 1: Security + Testing Complete"
|
||
```
|
||
|
||
- [ ] **Step 2: 推送所有提交**
|
||
|
||
```bash
|
||
cd g:/ZClaw_zclaw && git push origin main --tags
|
||
```
|
||
|
||
---
|
||
|
||
## Verification Checklist
|
||
|
||
### Security Verification
|
||
- [ ] localStorage 凭据已加密 (AES-GCM)
|
||
- [ ] 非 localhost 连接强制 WSS
|
||
- [ ] 加密密钥不暴露在日志中
|
||
- [ ] 向后兼容未加密数据
|
||
|
||
### Testing Verification
|
||
- [ ] 所有测试通过
|
||
- [ ] 覆盖率 >= 60%
|
||
- [ ] chatStore 覆盖率 >= 80%
|
||
- [ ] CI 集成就绪
|
||
|
||
### Documentation Verification
|
||
- [ ] 变更日志已创建
|
||
- [ ] 迁移说明已记录
|
||
- [ ] 破坏性变更已标注
|
||
|
||
---
|
||
|
||
## Next Steps (Phase 2)
|
||
|
||
完成 Phase 1 后,继续执行 Phase 2: 领域重组
|
||
|
||
- TASK-101: 创建 domains/ 目录结构
|
||
- TASK-102: 迁移 Chat Store 到 Valtio
|
||
- TASK-103: 迁移 Hands Store + XState
|
||
- TASK-104: 增强 Intelligence 缓存
|
||
- TASK-105: 提取共享模块
|
||
|
||
详见: `docs/superpowers/plans/2026-03-21-phase2-domain-reorganization.md` (待创建)
|