Files
zclaw_openfang/docs/superpowers/plans/2026-03-21-phase1-security-testing.md
iven 0d4fa96b82
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
refactor: 统一项目名称从OpenFang到ZCLAW
重构所有代码和文档中的项目名称,将OpenFang统一更新为ZCLAW。包括:
- 配置文件中的项目名称
- 代码注释和文档引用
- 环境变量和路径
- 类型定义和接口名称
- 测试用例和模拟数据

同时优化部分代码结构,移除未使用的模块,并更新相关依赖项。
2026-03-27 07:36:03 +08:00

32 KiB
Raw Blame History

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):

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):

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:

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:

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:

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:

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage"
  }
}
  • Step 4: 运行测试验证配置

Run:

cd g:/ZClaw_zclaw/desktop && pnpm test

Expected: 测试运行成功 (可能显示 0 tests这是正常的)

  • Step 5: 提交测试框架配置
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:

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:

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:

/**
 * 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:

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:

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:

cd g:/ZClaw_zclaw/desktop && pnpm test tests/lib/crypto-utils.test.ts

Expected: PASS - 3 tests

  • Step 7: 提交加密工具模块
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:

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:

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:

// 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:

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:

cd g:/ZClaw_zclaw/desktop && pnpm test tests/lib/secure-storage.test.ts

Expected: PASS - 4 tests

  • Step 7: 提交凭据加密增强
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:

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:

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:

// 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:

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:

cd g:/ZClaw_zclaw/desktop && pnpm test

Expected: All tests pass

  • Step 7: 提交 WSS 强制策略
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:

head -n 100 g:/ZClaw_zclaw/desktop/src/store/chatStore.ts
  • Step 2: 写失败的测试 - 基础功能

Create desktop/tests/store/chatStore.test.ts:

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:

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:

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:

cd g:/ZClaw_zclaw/desktop && pnpm test tests/store/chatStore.test.ts

Expected: PASS - 10+ tests

  • Step 6: 运行覆盖率报告

Run:

cd g:/ZClaw_zclaw/desktop && pnpm test:coverage tests/store/chatStore.test.ts

Expected: Coverage > 80% for chatStore

  • Step 7: 提交 ChatStore 测试
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:

cd g:/ZClaw_zclaw/desktop && pnpm test

Expected: All tests pass

  • Step 2: 生成覆盖率报告

Run:

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. 确认凭据值是加密的 (包含 ivdata 字段)
  • 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:

# 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: 提交文档更新
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 完成标签
cd g:/ZClaw_zclaw && git tag -a v0.3.0-phase1 -m "Phase 1: Security + Testing Complete"
  • Step 2: 推送所有提交
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 (待创建)