feat(ui): Phase 8 UI/UX optimization and system documentation update
## Sidebar Enhancement - Change tabs to icon + small label layout for better space utilization - Add Teams tab with team collaboration entry point ## Settings Page Improvements - Connect theme toggle to gatewayStore.saveQuickConfig for persistence - Remove OpenFang backend download section, simplify UI - Add time range filter to UsageStats (7d/30d/all) - Add stat cards with icons (sessions, messages, input/output tokens) - Add token usage overview bar chart - Add 8 ZCLAW system skill definitions with categories ## Bug Fixes - Fix ChannelList duplicate content with deduplication logic - Integrate CreateTriggerModal in TriggersPanel - Add independent SecurityStatusPanel with 12 default enabled layers - Change workflow view to use SchedulerPanel as unified entry ## New Components - CreateTriggerModal: Event trigger creation modal - HandApprovalModal: Hand approval workflow dialog - HandParamsForm: Enhanced Hand parameter form - SecurityLayersPanel: 16-layer security status display ## Architecture - Add TOML config parsing support (toml-utils.ts, config-parser.ts) - Add request timeout and retry mechanism (request-helper.ts) - Add secure token storage (secure-storage.ts, secure_storage.rs) ## Tests - Add unit tests for config-parser, toml-utils, request-helper - Add team-client and teamStore tests ## Documentation - Update SYSTEM_ANALYSIS.md with Phase 8 completion - UI completion: 100% (30/30 components) - API coverage: 93% (63/68 endpoints) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
377
desktop/src/lib/config-parser.ts
Normal file
377
desktop/src/lib/config-parser.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* OpenFang Configuration Parser
|
||||
*
|
||||
* Provides configuration parsing, validation, and serialization for OpenFang TOML files.
|
||||
*
|
||||
* @module lib/config-parser
|
||||
*/
|
||||
|
||||
import { tomlUtils, TomlParseError } from './toml-utils';
|
||||
import type {
|
||||
OpenFangConfig,
|
||||
ConfigValidationResult,
|
||||
ConfigValidationError,
|
||||
ConfigValidationWarning,
|
||||
ConfigFileMetadata,
|
||||
ServerConfig,
|
||||
AgentSectionConfig,
|
||||
LLMConfig,
|
||||
} from '../types/config';
|
||||
|
||||
/**
|
||||
* Error class for configuration parsing errors
|
||||
*/
|
||||
export class ConfigParseError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly cause?: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ConfigParseError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for configuration validation errors (thrown when validation fails)
|
||||
*/
|
||||
export class ConfigValidationFailedError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly errors: ConfigValidationError[]
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ConfigValidationFailedError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Required configuration fields with their paths
|
||||
*/
|
||||
const REQUIRED_FIELDS: Array<{ path: string; description: string }> = [
|
||||
{ path: 'server', description: 'Server configuration' },
|
||||
{ path: 'server.host', description: 'Server host address' },
|
||||
{ path: 'server.port', description: 'Server port number' },
|
||||
{ path: 'agent', description: 'Agent configuration' },
|
||||
{ path: 'agent.defaults', description: 'Agent defaults' },
|
||||
{ path: 'agent.defaults.workspace', description: 'Default workspace path' },
|
||||
{ path: 'agent.defaults.default_model', description: 'Default model name' },
|
||||
{ path: 'llm', description: 'LLM configuration' },
|
||||
{ path: 'llm.default_provider', description: 'Default LLM provider' },
|
||||
{ path: 'llm.default_model', description: 'Default LLM model' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Default configuration values
|
||||
*/
|
||||
const DEFAULT_CONFIG: Partial<OpenFangConfig> = {
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 50051,
|
||||
websocket_port: 50051,
|
||||
websocket_path: '/ws',
|
||||
api_version: 'v1',
|
||||
},
|
||||
agent: {
|
||||
defaults: {
|
||||
workspace: '~/.openfang/workspace',
|
||||
default_model: 'gpt-4',
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
default_provider: 'openai',
|
||||
default_model: 'gpt-4',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration parser and validator
|
||||
*/
|
||||
export const configParser = {
|
||||
/**
|
||||
* Parse TOML content into an OpenFang configuration object
|
||||
*
|
||||
* @param content - The TOML content to parse
|
||||
* @param envVars - Optional environment variables for resolution
|
||||
* @returns The parsed configuration object
|
||||
* @throws ConfigParseError if parsing fails
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const config = configParser.parseConfig(tomlContent, { OPENAI_API_KEY: 'sk-...' });
|
||||
* ```
|
||||
*/
|
||||
parseConfig: (content: string, envVars?: Record<string, string | undefined>): OpenFangConfig => {
|
||||
try {
|
||||
// First resolve environment variables
|
||||
const resolved = tomlUtils.resolveEnvVars(content, envVars);
|
||||
|
||||
// Parse TOML
|
||||
const parsed = tomlUtils.parse<OpenFangConfig>(resolved);
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if (error instanceof TomlParseError) {
|
||||
throw new ConfigParseError(`Failed to parse configuration: ${error.message}`, error);
|
||||
}
|
||||
throw new ConfigParseError(
|
||||
`Unexpected error parsing configuration: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Validate an OpenFang configuration object
|
||||
*
|
||||
* @param config - The configuration object to validate
|
||||
* @returns Validation result with errors and warnings
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = configParser.validateConfig(parsedConfig);
|
||||
* if (!result.valid) {
|
||||
* console.error('Config errors:', result.errors);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
validateConfig: (config: unknown): ConfigValidationResult => {
|
||||
const errors: ConfigValidationError[] = [];
|
||||
const warnings: ConfigValidationWarning[] = [];
|
||||
// Basic type check
|
||||
if (typeof config !== 'object' || config === null) {
|
||||
errors.push({
|
||||
path: '',
|
||||
message: 'Configuration must be a non-null object',
|
||||
severity: 'error',
|
||||
});
|
||||
return { valid: false, errors, warnings };
|
||||
}
|
||||
const cfg = config as Record<string, unknown>;
|
||||
// Check required fields
|
||||
for (const { path, description } of REQUIRED_FIELDS) {
|
||||
const value = getNestedValue(cfg, path);
|
||||
if (value === undefined) {
|
||||
errors.push({
|
||||
path,
|
||||
message: `Missing required field: ${description}`,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
// Validate server configuration
|
||||
if (cfg.server && typeof cfg.server === 'object') {
|
||||
const server = cfg.server as ServerConfig;
|
||||
if (typeof server.port === 'number' && (server.port < 1 || server.port > 65535)) {
|
||||
errors.push({
|
||||
path: 'server.port',
|
||||
message: 'Port must be between 1 and 65535',
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
if (typeof server.host === 'string' && server.host.length === 0) {
|
||||
errors.push({
|
||||
path: 'server.host',
|
||||
message: 'Host cannot be empty',
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
// Validate agent configuration
|
||||
if (cfg.agent && typeof cfg.agent === 'object') {
|
||||
const agent = cfg.agent as AgentSectionConfig;
|
||||
if (agent.defaults) {
|
||||
if (typeof agent.defaults.workspace === 'string' && agent.defaults.workspace.length === 0) {
|
||||
warnings.push({
|
||||
path: 'agent.defaults.workspace',
|
||||
message: 'Workspace path is empty',
|
||||
severity: 'warning',
|
||||
});
|
||||
}
|
||||
if (typeof agent.defaults.default_model === 'string' && agent.defaults.default_model.length === 0) {
|
||||
errors.push({
|
||||
path: 'agent.defaults.default_model',
|
||||
message: 'Default model cannot be empty',
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Validate LLM configuration
|
||||
if (cfg.llm && typeof cfg.llm === 'object') {
|
||||
const llm = cfg.llm as LLMConfig;
|
||||
if (typeof llm.default_provider === 'string' && llm.default_provider.length === 0) {
|
||||
errors.push({
|
||||
path: 'llm.default_provider',
|
||||
message: 'Default provider cannot be empty',
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
if (typeof llm.default_model === 'string' && llm.default_model.length === 0) {
|
||||
errors.push({
|
||||
path: 'llm.default_model',
|
||||
message: 'Default model cannot be empty',
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Parse and validate configuration in one step
|
||||
*
|
||||
* @param content - The TOML content to parse
|
||||
* @param envVars - Optional environment variables for resolution
|
||||
* @returns The parsed and validated configuration
|
||||
* @throws ConfigParseError if parsing fails
|
||||
* @throws ConfigValidationFailedError if validation fails
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const config = configParser.parseAndValidate(tomlContent);
|
||||
* ```
|
||||
*/
|
||||
parseAndValidate: (
|
||||
content: string,
|
||||
envVars?: Record<string, string | undefined>
|
||||
): OpenFangConfig => {
|
||||
const config = configParser.parseConfig(content, envVars);
|
||||
const result = configParser.validateConfig(config);
|
||||
if (!result.valid) {
|
||||
throw new ConfigValidationFailedError(
|
||||
`Configuration validation failed: ${result.errors.map((e) => e.message).join(', ')}`,
|
||||
result.errors
|
||||
);
|
||||
}
|
||||
return config;
|
||||
},
|
||||
|
||||
/**
|
||||
* Serialize a configuration object to TOML format
|
||||
*
|
||||
* @param config - The configuration object to serialize
|
||||
* @returns The TOML string
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const toml = configParser.stringifyConfig(config);
|
||||
* ```
|
||||
*/
|
||||
stringifyConfig: (config: OpenFangConfig): string => {
|
||||
return tomlUtils.stringify(config as unknown as Record<string, unknown>);
|
||||
},
|
||||
|
||||
/**
|
||||
* Merge partial configuration with defaults
|
||||
*
|
||||
* @param config - Partial configuration to merge
|
||||
* @returns Complete configuration with defaults applied
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const fullConfig = configParser.mergeWithDefaults(partialConfig);
|
||||
* ```
|
||||
*/
|
||||
mergeWithDefaults: (config: Partial<OpenFangConfig>): OpenFangConfig => {
|
||||
return deepMerge(DEFAULT_CONFIG, config) as unknown as OpenFangConfig;
|
||||
},
|
||||
|
||||
/**
|
||||
* Extract metadata from a TOML configuration file
|
||||
*
|
||||
* @param content - The TOML content
|
||||
* @param filePath - The file path
|
||||
* @returns Configuration file metadata
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const metadata = configParser.extractMetadata(tomlContent, '/path/to/config.toml');
|
||||
* console.log('Env vars needed:', metadata.envVars);
|
||||
* ```
|
||||
*/
|
||||
extractMetadata: (content: string, filePath: string): ConfigFileMetadata => {
|
||||
const envVars = tomlUtils.extractEnvVarNames(content);
|
||||
const hasUnresolvedEnvVars = tomlUtils.hasUnresolvedEnvVars(content);
|
||||
return {
|
||||
path: filePath,
|
||||
name: filePath.split('/').pop() || filePath,
|
||||
envVars,
|
||||
hasUnresolvedEnvVars,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get default configuration
|
||||
*
|
||||
* @returns Default OpenFang configuration
|
||||
*/
|
||||
getDefaults: (): OpenFangConfig => {
|
||||
return JSON.parse(JSON.stringify(DEFAULT_CONFIG)) as OpenFangConfig;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a configuration object is valid
|
||||
*
|
||||
* @param config - The configuration to check
|
||||
* @returns Type guard for OpenFangConfig
|
||||
*/
|
||||
isOpenFangConfig: (config: unknown): config is OpenFangConfig => {
|
||||
const result = configParser.validateConfig(config);
|
||||
return result.valid;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to get a nested value from an object using dot-notation path
|
||||
*/
|
||||
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
const parts = path.split('.');
|
||||
let current: unknown = obj;
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof current !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to deep merge two objects
|
||||
*/
|
||||
function deepMerge<T extends Record<string, unknown>>(
|
||||
target: Partial<T>,
|
||||
source: Partial<T>
|
||||
): Partial<T> {
|
||||
const result = { ...target };
|
||||
for (const key of Object.keys(source) as (keyof T)[]) {
|
||||
const sourceValue = source[key];
|
||||
const targetValue = target[key];
|
||||
if (
|
||||
sourceValue !== undefined &&
|
||||
typeof sourceValue === 'object' &&
|
||||
sourceValue !== null &&
|
||||
!Array.isArray(sourceValue) &&
|
||||
targetValue !== undefined &&
|
||||
typeof targetValue === 'object' &&
|
||||
targetValue !== null &&
|
||||
!Array.isArray(targetValue)
|
||||
) {
|
||||
result[key] = deepMerge(
|
||||
targetValue as Record<string, unknown>,
|
||||
sourceValue as Record<string, unknown>
|
||||
) as T[keyof T];
|
||||
} else if (sourceValue !== undefined) {
|
||||
// Safe assignment: sourceValue is typed as Partial<T>[keyof T], result[key] expects T[keyof T]
|
||||
result[key] = sourceValue as T[keyof T];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export default configParser;
|
||||
@@ -1241,6 +1241,16 @@ export class GatewayClient {
|
||||
return this.restGet(`/api/audit/logs?${params}`);
|
||||
}
|
||||
|
||||
/** Verify audit log chain for a specific log entry */
|
||||
async verifyAuditLogChain(logId: string): Promise<{
|
||||
valid: boolean;
|
||||
chain_depth?: number;
|
||||
root_hash?: string;
|
||||
broken_at_index?: number;
|
||||
}> {
|
||||
return this.restGet(`/api/audit/verify/${logId}`);
|
||||
}
|
||||
|
||||
// === OpenFang Security API ===
|
||||
|
||||
/** Get security status */
|
||||
|
||||
542
desktop/src/lib/request-helper.ts
Normal file
542
desktop/src/lib/request-helper.ts
Normal file
@@ -0,0 +1,542 @@
|
||||
/**
|
||||
* Request Helper Module
|
||||
*
|
||||
* Provides request timeout, automatic retry with exponential backoff,
|
||||
* and request cancellation support for API clients.
|
||||
*
|
||||
* @module lib/request-helper
|
||||
*/
|
||||
|
||||
// === Configuration Types ===
|
||||
|
||||
export interface RequestConfig {
|
||||
/** Timeout in milliseconds, default 30000 */
|
||||
timeout?: number;
|
||||
/** Number of retry attempts, default 3 */
|
||||
retries?: number;
|
||||
/** Base retry delay in milliseconds, default 1000 (exponential backoff applied) */
|
||||
retryDelay?: number;
|
||||
/** HTTP status codes that trigger retry, default [408, 429, 500, 502, 503, 504] */
|
||||
retryOn?: number[];
|
||||
/** Maximum retry delay cap in milliseconds, default 30000 */
|
||||
maxRetryDelay?: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_REQUEST_CONFIG: Required<RequestConfig> = {
|
||||
timeout: 30000,
|
||||
retries: 3,
|
||||
retryDelay: 1000,
|
||||
retryOn: [408, 429, 500, 502, 503, 504],
|
||||
maxRetryDelay: 30000,
|
||||
};
|
||||
|
||||
// === Error Types ===
|
||||
|
||||
export class RequestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
public readonly statusText: string,
|
||||
public readonly responseBody?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'RequestError';
|
||||
}
|
||||
|
||||
/** Check if error is retryable based on status code */
|
||||
isRetryable(retryCodes: number[] = DEFAULT_REQUEST_CONFIG.retryOn): boolean {
|
||||
return retryCodes.includes(this.status);
|
||||
}
|
||||
|
||||
/** Check if error is a timeout */
|
||||
isTimeout(): boolean {
|
||||
return this.status === 408 || this.message.includes('timeout');
|
||||
}
|
||||
|
||||
/** Check if error is an authentication error (should NOT retry) */
|
||||
isAuthError(): boolean {
|
||||
return this.status === 401 || this.status === 403;
|
||||
}
|
||||
}
|
||||
|
||||
export class RequestCancelledError extends Error {
|
||||
constructor(message: string = 'Request cancelled') {
|
||||
super(message);
|
||||
this.name = 'RequestCancelledError';
|
||||
}
|
||||
}
|
||||
|
||||
// === Helper Functions ===
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate exponential backoff delay with jitter
|
||||
* @param baseDelay Base delay in ms
|
||||
* @param attempt Current attempt number (0-indexed)
|
||||
* @param maxDelay Maximum delay cap
|
||||
* @returns Delay in milliseconds
|
||||
*/
|
||||
function calculateBackoff(
|
||||
baseDelay: number,
|
||||
attempt: number,
|
||||
maxDelay: number = DEFAULT_REQUEST_CONFIG.maxRetryDelay
|
||||
): number {
|
||||
// Exponential backoff: baseDelay * 2^attempt
|
||||
const exponentialDelay = baseDelay * Math.pow(2, attempt);
|
||||
// Cap at maxDelay
|
||||
const cappedDelay = Math.min(exponentialDelay, maxDelay);
|
||||
// Add jitter (0-25% of delay) to prevent thundering herd
|
||||
const jitter = cappedDelay * 0.25 * Math.random();
|
||||
return Math.floor(cappedDelay + jitter);
|
||||
}
|
||||
|
||||
// === Request with Retry ===
|
||||
|
||||
export interface RequestWithRetryOptions extends RequestInit {
|
||||
/** Request configuration for timeout and retry */
|
||||
config?: RequestConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a fetch request with timeout and automatic retry support.
|
||||
*
|
||||
* Features:
|
||||
* - Configurable timeout with AbortController
|
||||
* - Automatic retry with exponential backoff
|
||||
* - Configurable retry status codes
|
||||
* - Jitter to prevent thundering herd
|
||||
*
|
||||
* @param url Request URL
|
||||
* @param options Fetch options + request config
|
||||
* @param config Request configuration (timeout, retries, etc.)
|
||||
* @returns Promise<Response>
|
||||
* @throws RequestError on failure after all retries exhausted
|
||||
* @throws RequestCancelledError if request was cancelled
|
||||
*/
|
||||
export async function requestWithRetry(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
config: RequestConfig = {}
|
||||
): Promise<Response> {
|
||||
const {
|
||||
timeout = DEFAULT_REQUEST_CONFIG.timeout,
|
||||
retries = DEFAULT_REQUEST_CONFIG.retries,
|
||||
retryDelay = DEFAULT_REQUEST_CONFIG.retryDelay,
|
||||
retryOn = DEFAULT_REQUEST_CONFIG.retryOn,
|
||||
} = config;
|
||||
|
||||
let lastError: RequestError | null = null;
|
||||
let responseBody = '';
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
// Try to read response body for error details
|
||||
try {
|
||||
responseBody = await response.text();
|
||||
} catch {
|
||||
responseBody = '';
|
||||
}
|
||||
|
||||
const error = new RequestError(
|
||||
`Request failed: ${response.status} ${response.statusText}`,
|
||||
response.status,
|
||||
response.statusText,
|
||||
responseBody
|
||||
);
|
||||
|
||||
// Check if we should retry
|
||||
if (retryOn.includes(response.status) && attempt < retries) {
|
||||
const backoff = calculateBackoff(retryDelay, attempt);
|
||||
console.warn(
|
||||
`[RequestHelper] Request failed (${response.status}), ` +
|
||||
`retrying in ${backoff}ms (attempt ${attempt + 1}/${retries})`
|
||||
);
|
||||
await delay(backoff);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Success - return response
|
||||
return response;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Re-throw RequestError (already formatted)
|
||||
if (error instanceof RequestError) {
|
||||
lastError = error;
|
||||
|
||||
// Check if we should retry
|
||||
if (error.isRetryable(retryOn) && attempt < retries) {
|
||||
const backoff = calculateBackoff(retryDelay, attempt);
|
||||
console.warn(
|
||||
`[RequestHelper] Request error (${error.status}), ` +
|
||||
`retrying in ${backoff}ms (attempt ${attempt + 1}/${retries})`
|
||||
);
|
||||
await delay(backoff);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Handle AbortError (timeout)
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
const timeoutError = new RequestError(
|
||||
`Request timeout after ${timeout}ms`,
|
||||
408,
|
||||
'Request Timeout'
|
||||
);
|
||||
|
||||
// Retry on timeout
|
||||
if (attempt < retries) {
|
||||
const backoff = calculateBackoff(retryDelay, attempt);
|
||||
console.warn(
|
||||
`[RequestHelper] Request timed out, ` +
|
||||
`retrying in ${backoff}ms (attempt ${attempt + 1}/${retries})`
|
||||
);
|
||||
await delay(backoff);
|
||||
lastError = timeoutError;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw timeoutError;
|
||||
}
|
||||
|
||||
// Handle cancellation
|
||||
if (error instanceof RequestCancelledError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Unknown error - wrap and throw
|
||||
throw new RequestError(
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
0,
|
||||
'Unknown Error',
|
||||
error instanceof Error ? error.stack : String(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// All retries exhausted
|
||||
throw lastError || new RequestError('All retry attempts exhausted', 0, 'Retry Exhausted');
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a request and parse JSON response.
|
||||
* Combines requestWithRetry with JSON parsing.
|
||||
*
|
||||
* @param url Request URL
|
||||
* @param options Fetch options
|
||||
* @param config Request configuration
|
||||
* @returns Parsed JSON response
|
||||
*/
|
||||
export async function requestJson<T = unknown>(
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
config: RequestConfig = {}
|
||||
): Promise<T> {
|
||||
const response = await requestWithRetry(url, options, config);
|
||||
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
throw new RequestError(
|
||||
`Failed to parse JSON response: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
0,
|
||||
'Parse Error',
|
||||
await response.text().catch(() => '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// === Request Manager (Cancellation Support) ===
|
||||
|
||||
/**
|
||||
* Manages multiple concurrent requests with cancellation support.
|
||||
* Provides centralized control over request lifecycle.
|
||||
*/
|
||||
export class RequestManager {
|
||||
private controllers = new Map<string, AbortController>();
|
||||
private requestConfigs = new Map<string, RequestConfig>();
|
||||
|
||||
/**
|
||||
* Create a new request with an ID for tracking.
|
||||
* Returns the AbortController for signal attachment.
|
||||
*
|
||||
* @param id Unique request identifier
|
||||
* @param config Optional request configuration
|
||||
* @returns AbortController for the request
|
||||
*/
|
||||
createRequest(id: string, config?: RequestConfig): AbortController {
|
||||
// Cancel existing request with same ID
|
||||
if (this.controllers.has(id)) {
|
||||
this.cancelRequest(id);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
this.controllers.set(id, controller);
|
||||
if (config) {
|
||||
this.requestConfigs.set(id, config);
|
||||
}
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a managed request with automatic tracking.
|
||||
* The request will be automatically removed when complete.
|
||||
*
|
||||
* @param id Unique request identifier
|
||||
* @param url Request URL
|
||||
* @param options Fetch options
|
||||
* @param config Request configuration
|
||||
* @returns Response promise
|
||||
*/
|
||||
async executeManaged(
|
||||
id: string,
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
config: RequestConfig = {}
|
||||
): Promise<Response> {
|
||||
const controller = this.createRequest(id, config);
|
||||
|
||||
try {
|
||||
const response = await requestWithRetry(
|
||||
url,
|
||||
{
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
},
|
||||
config
|
||||
);
|
||||
|
||||
// Clean up on success
|
||||
this.controllers.delete(id);
|
||||
this.requestConfigs.delete(id);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Clean up on error
|
||||
this.controllers.delete(id);
|
||||
this.requestConfigs.delete(id);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a managed JSON request with automatic tracking.
|
||||
*
|
||||
* @param id Unique request identifier
|
||||
* @param url Request URL
|
||||
* @param options Fetch options
|
||||
* @param config Request configuration
|
||||
* @returns Parsed JSON response
|
||||
*/
|
||||
async executeManagedJson<T = unknown>(
|
||||
id: string,
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
config: RequestConfig = {}
|
||||
): Promise<T> {
|
||||
const response = await this.executeManaged(id, url, options, config);
|
||||
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
throw new RequestError(
|
||||
`Failed to parse JSON response: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
0,
|
||||
'Parse Error',
|
||||
await response.text().catch(() => '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a specific request by ID.
|
||||
*
|
||||
* @param id Request identifier
|
||||
* @returns true if request was cancelled, false if not found
|
||||
*/
|
||||
cancelRequest(id: string): boolean {
|
||||
const controller = this.controllers.get(id);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
this.controllers.delete(id);
|
||||
this.requestConfigs.delete(id);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request is currently in progress.
|
||||
*
|
||||
* @param id Request identifier
|
||||
* @returns true if request is active
|
||||
*/
|
||||
isRequestActive(id: string): boolean {
|
||||
return this.controllers.has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active request IDs.
|
||||
*
|
||||
* @returns Array of active request IDs
|
||||
*/
|
||||
getActiveRequestIds(): string[] {
|
||||
return Array.from(this.controllers.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all active requests.
|
||||
*/
|
||||
cancelAll(): void {
|
||||
this.controllers.forEach((controller, id) => {
|
||||
controller.abort();
|
||||
console.log(`[RequestManager] Cancelled request: ${id}`);
|
||||
});
|
||||
this.controllers.clear();
|
||||
this.requestConfigs.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of active requests.
|
||||
*/
|
||||
get activeCount(): number {
|
||||
return this.controllers.size;
|
||||
}
|
||||
}
|
||||
|
||||
// === Default Request Manager Instance ===
|
||||
|
||||
/**
|
||||
* Global request manager instance for application-wide request tracking.
|
||||
* Use this for simple cases; create new instances for isolated contexts.
|
||||
*/
|
||||
export const globalRequestManager = new RequestManager();
|
||||
|
||||
// === Convenience Functions ===
|
||||
|
||||
/**
|
||||
* Create a GET request with retry support.
|
||||
*/
|
||||
export async function get(
|
||||
url: string,
|
||||
headers?: HeadersInit,
|
||||
config?: RequestConfig
|
||||
): Promise<Response> {
|
||||
return requestWithRetry(url, { method: 'GET', headers }, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a POST request with retry support.
|
||||
*/
|
||||
export async function post(
|
||||
url: string,
|
||||
body?: unknown,
|
||||
headers?: HeadersInit,
|
||||
config?: RequestConfig
|
||||
): Promise<Response> {
|
||||
return requestWithRetry(
|
||||
url,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
},
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a PUT request with retry support.
|
||||
*/
|
||||
export async function put(
|
||||
url: string,
|
||||
body?: unknown,
|
||||
headers?: HeadersInit,
|
||||
config?: RequestConfig
|
||||
): Promise<Response> {
|
||||
return requestWithRetry(
|
||||
url,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
},
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a DELETE request with retry support.
|
||||
*/
|
||||
export async function del(
|
||||
url: string,
|
||||
headers?: HeadersInit,
|
||||
config?: RequestConfig
|
||||
): Promise<Response> {
|
||||
return requestWithRetry(url, { method: 'DELETE', headers }, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a PATCH request with retry support.
|
||||
*/
|
||||
export async function patch(
|
||||
url: string,
|
||||
body?: unknown,
|
||||
headers?: HeadersInit,
|
||||
config?: RequestConfig
|
||||
): Promise<Response> {
|
||||
return requestWithRetry(
|
||||
url,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
},
|
||||
config
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
requestWithRetry,
|
||||
requestJson,
|
||||
RequestManager,
|
||||
globalRequestManager,
|
||||
RequestError,
|
||||
RequestCancelledError,
|
||||
get,
|
||||
post,
|
||||
put,
|
||||
del,
|
||||
patch,
|
||||
DEFAULT_REQUEST_CONFIG,
|
||||
};
|
||||
181
desktop/src/lib/secure-storage.ts
Normal file
181
desktop/src/lib/secure-storage.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* ZCLAW Secure Storage
|
||||
*
|
||||
* Provides secure credential storage using the OS keyring/keychain.
|
||||
* Falls back to localStorage when not running in Tauri or if keyring is unavailable.
|
||||
*
|
||||
* Platform support:
|
||||
* - Windows: DPAPI
|
||||
* - macOS: Keychain
|
||||
* - Linux: Secret Service API (gnome-keyring, kwallet, etc.)
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { isTauriRuntime } from './tauri-gateway';
|
||||
|
||||
// Cache for keyring availability check
|
||||
let keyringAvailable: boolean | null = null;
|
||||
|
||||
/**
|
||||
* Check if secure storage (keyring) is available
|
||||
*/
|
||||
export async function isSecureStorageAvailable(): Promise<boolean> {
|
||||
if (!isTauriRuntime()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use cached result if available
|
||||
if (keyringAvailable !== null) {
|
||||
return keyringAvailable;
|
||||
}
|
||||
|
||||
try {
|
||||
keyringAvailable = await invoke<boolean>('secure_store_is_available');
|
||||
return keyringAvailable;
|
||||
} catch (error) {
|
||||
console.warn('[SecureStorage] Keyring not available:', error);
|
||||
keyringAvailable = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Secure storage interface
|
||||
* Uses OS keyring when available, falls back to localStorage
|
||||
*/
|
||||
export const secureStorage = {
|
||||
/**
|
||||
* Store a value securely
|
||||
* @param key - Storage key
|
||||
* @param value - Value to store
|
||||
*/
|
||||
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 to localStorage as backup/migration support
|
||||
writeLocalStorageBackup(key, trimmedValue);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn('[SecureStorage] Failed to use keyring, falling back to localStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to localStorage
|
||||
writeLocalStorageBackup(key, trimmedValue);
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve a value from secure storage
|
||||
* @param key - Storage key
|
||||
* @returns Stored value or null if not found
|
||||
*/
|
||||
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;
|
||||
}
|
||||
// If keyring returned empty, try localStorage fallback for migration
|
||||
return readLocalStorageBackup(key);
|
||||
} catch (error) {
|
||||
console.warn('[SecureStorage] Failed to read from keyring, trying localStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to localStorage
|
||||
return readLocalStorageBackup(key);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a value from secure storage
|
||||
* @param key - Storage key
|
||||
*/
|
||||
async delete(key: string): Promise<void> {
|
||||
if (await isSecureStorageAvailable()) {
|
||||
try {
|
||||
await invoke('secure_store_delete', { key });
|
||||
} catch (error) {
|
||||
console.warn('[SecureStorage] Failed to delete from keyring:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Always clear localStorage backup
|
||||
clearLocalStorageBackup(key);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if secure storage is being used (vs localStorage fallback)
|
||||
*/
|
||||
async isUsingKeyring(): Promise<boolean> {
|
||||
return isSecureStorageAvailable();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* localStorage backup functions for migration and fallback
|
||||
*/
|
||||
function writeLocalStorageBackup(key: string, value: string): void {
|
||||
try {
|
||||
if (value) {
|
||||
localStorage.setItem(key, value);
|
||||
} else {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage failures
|
||||
}
|
||||
}
|
||||
|
||||
function readLocalStorageBackup(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearLocalStorageBackup(key: string): void {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Ignore localStorage failures
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous versions for compatibility with existing code
|
||||
* These use localStorage only and are provided for gradual migration
|
||||
*/
|
||||
export const secureStorageSync = {
|
||||
/**
|
||||
* Synchronously get a value from localStorage (for migration only)
|
||||
* @deprecated Use async secureStorage.get() instead
|
||||
*/
|
||||
get(key: string): string | null {
|
||||
return readLocalStorageBackup(key);
|
||||
},
|
||||
|
||||
/**
|
||||
* Synchronously set a value in localStorage (for migration only)
|
||||
* @deprecated Use async secureStorage.set() instead
|
||||
*/
|
||||
set(key: string, value: string): void {
|
||||
writeLocalStorageBackup(key, value);
|
||||
},
|
||||
|
||||
/**
|
||||
* Synchronously delete a value from localStorage (for migration only)
|
||||
* @deprecated Use async secureStorage.delete() instead
|
||||
*/
|
||||
delete(key: string): void {
|
||||
clearLocalStorageBackup(key);
|
||||
},
|
||||
};
|
||||
186
desktop/src/lib/toml-utils.ts
Normal file
186
desktop/src/lib/toml-utils.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* TOML Utility Functions
|
||||
*
|
||||
* Provides TOML parsing and serialization capabilities for OpenFang configuration files.
|
||||
* Supports environment variable interpolation in the format ${VAR_NAME}.
|
||||
*
|
||||
* @module toml-utils
|
||||
*/
|
||||
|
||||
import TOML from 'smol-toml';
|
||||
|
||||
/**
|
||||
* Error class for TOML parsing errors
|
||||
*/
|
||||
export class TomlParseError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly cause?: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'TomlParseError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for TOML serialization errors
|
||||
*/
|
||||
export class TomlStringifyError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly cause?: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'TomlStringifyError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TOML utility functions for parsing and serializing configuration files
|
||||
*/
|
||||
export const tomlUtils = {
|
||||
/**
|
||||
* Parse a TOML string into a JavaScript object
|
||||
*
|
||||
* @param content - The TOML string to parse
|
||||
* @returns The parsed JavaScript object
|
||||
* @throws TomlParseError if the TOML content is invalid
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const config = tomlUtils.parse(`
|
||||
* [server]
|
||||
* host = "127.0.0.1"
|
||||
* port = 4200
|
||||
* `);
|
||||
* // config = { server: { host: "127.0.0.1", port: 4200 } }
|
||||
* ```
|
||||
*/
|
||||
parse: <T = Record<string, unknown>>(content: string): T => {
|
||||
try {
|
||||
return TOML.parse(content) as T;
|
||||
} catch (error) {
|
||||
console.error('[TOML] Parse error:', error);
|
||||
throw new TomlParseError(
|
||||
`TOML parse error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Serialize a JavaScript object to a TOML string
|
||||
*
|
||||
* @param data - The JavaScript object to serialize
|
||||
* @returns The TOML string representation
|
||||
* @throws TomlStringifyError if the object cannot be serialized to TOML
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const toml = tomlUtils.stringify({
|
||||
* server: { host: "127.0.0.1", port: 4200 }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
stringify: (data: Record<string, unknown>): string => {
|
||||
try {
|
||||
return TOML.stringify(data);
|
||||
} catch (error) {
|
||||
console.error('[TOML] Stringify error:', error);
|
||||
throw new TomlStringifyError(
|
||||
`TOML stringify error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve environment variables in TOML content
|
||||
*
|
||||
* Replaces ${VAR_NAME} patterns with the corresponding environment variable values.
|
||||
* If the environment variable is not set, it's replaced with an empty string.
|
||||
*
|
||||
* Note: In browser/Tauri context, this function has limited access to environment
|
||||
* variables. For full resolution, use the Tauri backend to read env vars.
|
||||
*
|
||||
* @param content - The TOML content with potential ${VAR_NAME} patterns
|
||||
* @param envVars - Optional object containing environment variables (for testing or Tauri-provided values)
|
||||
* @returns The content with environment variables resolved
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const content = 'api_key = "${OPENAI_API_KEY}"';
|
||||
* const resolved = tomlUtils.resolveEnvVars(content, { OPENAI_API_KEY: 'sk-...' });
|
||||
* // resolved = 'api_key = "sk-..."'
|
||||
* ```
|
||||
*/
|
||||
resolveEnvVars: (
|
||||
content: string,
|
||||
envVars?: Record<string, string | undefined>
|
||||
): string => {
|
||||
return content.replace(/\$\{([^}]+)\}/g, (_, varName: string) => {
|
||||
// If envVars provided, use them; otherwise try to access from window or return empty
|
||||
if (envVars) {
|
||||
return envVars[varName] ?? '';
|
||||
}
|
||||
|
||||
// In browser context, we can't access process.env directly
|
||||
// This will be handled by passing envVars from Tauri backend
|
||||
console.warn(
|
||||
`[TOML] Environment variable ${varName} not resolved - no envVars provided`
|
||||
);
|
||||
return '';
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Parse TOML content with environment variable resolution
|
||||
*
|
||||
* Convenience method that combines resolveEnvVars and parse.
|
||||
*
|
||||
* @param content - The TOML content with potential ${VAR_NAME} patterns
|
||||
* @param envVars - Optional object containing environment variables
|
||||
* @returns The parsed and resolved JavaScript object
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const config = tomlUtils.parseWithEnvVars(tomlContent, {
|
||||
* ZHIPU_API_KEY: 'your-api-key'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
parseWithEnvVars: <T = Record<string, unknown>>(
|
||||
content: string,
|
||||
envVars?: Record<string, string | undefined>
|
||||
): T => {
|
||||
const resolved = tomlUtils.resolveEnvVars(content, envVars);
|
||||
return tomlUtils.parse<T>(resolved);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a string contains unresolved environment variable placeholders
|
||||
*
|
||||
* @param content - The content to check
|
||||
* @returns true if there are unresolved ${VAR_NAME} patterns
|
||||
*/
|
||||
hasUnresolvedEnvVars: (content: string): boolean => {
|
||||
return /\$\{[^}]+\}/.test(content);
|
||||
},
|
||||
|
||||
/**
|
||||
* Extract environment variable names from TOML content
|
||||
*
|
||||
* @param content - The TOML content to scan
|
||||
* @returns Array of environment variable names found
|
||||
*/
|
||||
extractEnvVarNames: (content: string): string[] => {
|
||||
const matches = content.matchAll(/\$\{([^}]+)\}/g);
|
||||
const names = new Set<string>();
|
||||
for (const match of matches) {
|
||||
names.add(match[1]);
|
||||
}
|
||||
return Array.from(names);
|
||||
},
|
||||
};
|
||||
|
||||
export default tomlUtils;
|
||||
Reference in New Issue
Block a user