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
- 将 intelligence/llm/memory/browser 模块的 dead_code 注释从模糊的 "reserved for future" 改为明确说明 Tauri invoke_handler 运行时注册机制 - 为 identity.rs 中 3 个真正未使用的方法添加 #[allow(dead_code)] - 实现 compactor use_llm: true 功能:新增 compact_with_llm 方法和 compactor_compact_llm Tauri 命令,支持 LLM 驱动的对话摘要生成 - 将 pipeline_commands.rs 中 40+ 处 println!/eprintln! 调试输出替换为 tracing::debug!/warn!/error! 结构化日志 - 移除 intelligence/mod.rs 中不必要的 #[allow(unused_imports)]
942 lines
30 KiB
TypeScript
942 lines
30 KiB
TypeScript
/**
|
|
* OpenFang Mock Server for Testing
|
|
*
|
|
* Simulates OpenFang Kernel API endpoints and WebSocket events.
|
|
* Provides a complete test double for the ZCLAW desktop client.
|
|
*
|
|
* Usage:
|
|
* const server = createOpenFangMockServer({ port: 4200 });
|
|
* await server.start();
|
|
* // ... run tests ...
|
|
* await server.stop();
|
|
*/
|
|
|
|
import { createServer, IncomingMessage, Server, ServerResponse } from 'http';
|
|
import { WebSocketServer, WebSocket, RawData } from 'ws';
|
|
|
|
// === Types ===
|
|
|
|
export interface MockServerConfig {
|
|
port?: number;
|
|
host?: string;
|
|
version?: string;
|
|
connectionDelay?: number;
|
|
challengeDelay?: number;
|
|
}
|
|
|
|
export interface MockServerInstance {
|
|
start: () => Promise<void>;
|
|
stop: () => Promise<void>;
|
|
getPort: () => number;
|
|
getWsUrl: () => string;
|
|
getHttpUrl: () => string;
|
|
reset: () => void;
|
|
setHands: (hands: MockHand[]) => void;
|
|
setWorkflows: (workflows: MockWorkflow[]) => void;
|
|
setTriggers: (triggers: MockTrigger[]) => void;
|
|
setAgents: (agents: MockAgent[]) => void;
|
|
setSecurityLayers: (layers: MockSecurityLayer[]) => void;
|
|
addAuditLog: (entry: Omit<MockAuditLogEntry, 'id' | 'timestamp'>) => void;
|
|
simulateStreamEvent: (event: string, payload: unknown) => void;
|
|
getConnectedClients: () => number;
|
|
}
|
|
|
|
export interface MockHand {
|
|
name: string;
|
|
description: string;
|
|
status: 'idle' | 'running' | 'needs_approval' | 'completed' | 'error';
|
|
config?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface MockWorkflow {
|
|
id: string;
|
|
name: string;
|
|
steps: number;
|
|
description?: string;
|
|
}
|
|
|
|
export interface MockTrigger {
|
|
id: string;
|
|
type: 'webhook' | 'schedule' | 'event';
|
|
enabled: boolean;
|
|
config?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface MockAgent {
|
|
id: string;
|
|
name: string;
|
|
role?: string;
|
|
nickname?: string;
|
|
scenarios?: string[];
|
|
model?: string;
|
|
workspaceDir?: string;
|
|
workspaceResolvedPath?: string;
|
|
restrictFiles?: boolean;
|
|
privacyOptIn?: boolean;
|
|
userName?: string;
|
|
userRole?: string;
|
|
createdAt: string;
|
|
updatedAt?: string;
|
|
bootstrapReady?: boolean;
|
|
}
|
|
|
|
export interface MockSecurityLayer {
|
|
name: string;
|
|
enabled: boolean;
|
|
description?: string;
|
|
}
|
|
|
|
export interface MockAuditLogEntry {
|
|
id: string;
|
|
timestamp: string;
|
|
action: string;
|
|
actor?: string;
|
|
result?: 'success' | 'failure';
|
|
details?: Record<string, unknown>;
|
|
}
|
|
|
|
// === Sample Data ===
|
|
|
|
const DEFAULT_HANDS: MockHand[] = [
|
|
{ name: 'clip', description: 'Video processing and vertical screen generation', status: 'idle' },
|
|
{ name: 'lead', description: 'Sales lead discovery and qualification', status: 'idle' },
|
|
{ name: 'collector', description: 'Data collection and aggregation', status: 'idle' },
|
|
{ name: 'predictor', description: 'Predictive analytics', status: 'idle' },
|
|
{ name: 'researcher', description: 'Deep research and analysis', status: 'idle' },
|
|
{ name: 'twitter', description: 'Twitter automation', status: 'idle' },
|
|
{ name: 'browser', description: 'Browser automation', status: 'idle' },
|
|
];
|
|
|
|
const DEFAULT_WORKFLOWS: MockWorkflow[] = [
|
|
{ id: 'wf-001', name: 'Daily Report', steps: 3, description: 'Generate daily summary report' },
|
|
{ id: 'wf-002', name: 'Data Pipeline', steps: 5, description: 'ETL data processing pipeline' },
|
|
{ id: 'wf-003', name: 'Research Task', steps: 4, description: 'Multi-step research workflow' },
|
|
];
|
|
|
|
const DEFAULT_TRIGGERS: MockTrigger[] = [
|
|
{ id: 'tr-001', type: 'schedule', enabled: true, config: { cron: '0 9 * * *' } },
|
|
{ id: 'tr-002', type: 'webhook', enabled: true, config: { path: '/hooks/data' } },
|
|
{ id: 'tr-003', type: 'event', enabled: false, config: { eventType: 'data.received' } },
|
|
];
|
|
|
|
const DEFAULT_AGENTS: MockAgent[] = [
|
|
{
|
|
id: 'agent-001',
|
|
name: 'Default Agent',
|
|
role: 'assistant',
|
|
nickname: 'ZCLAW',
|
|
scenarios: ['general', 'coding'],
|
|
model: 'gpt-4',
|
|
workspaceDir: '~/.openfang/workspaces/default',
|
|
workspaceResolvedPath: '/home/user/.openfang/workspaces/default',
|
|
restrictFiles: false,
|
|
privacyOptIn: false,
|
|
userName: 'User',
|
|
userRole: 'developer',
|
|
createdAt: new Date().toISOString(),
|
|
bootstrapReady: true,
|
|
},
|
|
];
|
|
|
|
const DEFAULT_SECURITY_LAYERS: MockSecurityLayer[] = [
|
|
{ name: 'input_validation', enabled: true, description: 'Input sanitization and validation' },
|
|
{ name: 'rate_limiting', enabled: true, description: 'Request rate limiting' },
|
|
{ name: 'authentication', enabled: true, description: 'Ed25519 + JWT authentication' },
|
|
{ name: 'authorization', enabled: true, description: 'RBAC capability gates' },
|
|
{ name: 'encryption_at_rest', enabled: true, description: 'Data encryption at rest' },
|
|
{ name: 'encryption_in_transit', enabled: true, description: 'TLS encryption' },
|
|
{ name: 'audit_logging', enabled: true, description: 'Merkle hash chain audit' },
|
|
{ name: 'session_management', enabled: true, description: 'Secure session handling' },
|
|
{ name: 'device_attestation', enabled: true, description: 'Device identity verification' },
|
|
{ name: 'content_security', enabled: true, description: 'Content Security Policy' },
|
|
{ name: 'csp_headers', enabled: true, description: 'HTTP security headers' },
|
|
{ name: 'cors_policy', enabled: true, description: 'Cross-origin resource policy' },
|
|
{ name: 'sandbox_isolation', enabled: true, description: 'Process sandboxing' },
|
|
{ name: 'secret_management', enabled: true, description: 'Secure secret storage' },
|
|
{ name: 'intrusion_detection', enabled: false, description: 'IDS monitoring' },
|
|
{ name: 'backup_recovery', enabled: true, description: 'Backup and disaster recovery' },
|
|
];
|
|
|
|
// === Server Implementation ===
|
|
|
|
export function createOpenFangMockServer(config: MockServerConfig = {}): MockServerInstance {
|
|
const {
|
|
port = 4200,
|
|
host = '127.0.0.1',
|
|
version = '2026.3.13',
|
|
connectionDelay = 0,
|
|
challengeDelay = 0,
|
|
} = config;
|
|
|
|
let httpServer: Server | null = null;
|
|
let wsServer: WebSocketServer | null = null;
|
|
let actualPort = port;
|
|
|
|
// Mutable state
|
|
let hands = [...DEFAULT_HANDS];
|
|
let workflows = [...DEFAULT_WORKFLOWS];
|
|
let triggers = [...DEFAULT_TRIGGERS];
|
|
let agents = [...DEFAULT_AGENTS];
|
|
let securityLayers = [...DEFAULT_SECURITY_LAYERS];
|
|
let auditLogs: MockAuditLogEntry[] = [];
|
|
let handRuns = new Map<string, { status: string; result?: unknown; startedAt: string }>();
|
|
let workflowRuns = new Map<string, { status: string; step?: string; result?: unknown }>();
|
|
const connectedClients = new Set<WebSocket>();
|
|
|
|
// === REST API Handlers ===
|
|
|
|
function handleHealth(_req: IncomingMessage, res: ServerResponse): void {
|
|
sendJson(res, { version, status: 'ok' });
|
|
}
|
|
|
|
function handleGetAgents(_req: IncomingMessage, res: ServerResponse): void {
|
|
sendJson(res, { clones: agents });
|
|
}
|
|
|
|
function handleCreateAgent(req: IncomingMessage, res: ServerResponse): void {
|
|
parseBody(req, (body) => {
|
|
const newAgent: MockAgent = {
|
|
id: `agent-${Date.now()}`,
|
|
name: body.name || 'New Agent',
|
|
role: body.role,
|
|
nickname: body.nickname,
|
|
scenarios: body.scenarios,
|
|
model: body.model,
|
|
workspaceDir: body.workspaceDir,
|
|
restrictFiles: body.restrictFiles,
|
|
privacyOptIn: body.privacyOptIn,
|
|
userName: body.userName,
|
|
userRole: body.userRole,
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
agents.push(newAgent);
|
|
addAuditLogEntry({
|
|
action: 'agent.created',
|
|
actor: 'system',
|
|
result: 'success',
|
|
details: { agentId: newAgent.id, name: newAgent.name },
|
|
});
|
|
sendJson(res, { clone: newAgent }, 201);
|
|
});
|
|
}
|
|
|
|
function handleUpdateAgent(req: IncomingMessage, res: ServerResponse, agentId: string): void {
|
|
parseBody(req, (body) => {
|
|
const index = agents.findIndex((a) => a.id === agentId);
|
|
if (index === -1) {
|
|
sendError(res, 'Agent not found', 404);
|
|
return;
|
|
}
|
|
agents[index] = { ...agents[index], ...body, updatedAt: new Date().toISOString() };
|
|
addAuditLogEntry({
|
|
action: 'agent.updated',
|
|
actor: 'system',
|
|
result: 'success',
|
|
details: { agentId, updates: body },
|
|
});
|
|
sendJson(res, { clone: agents[index] });
|
|
});
|
|
}
|
|
|
|
function handleDeleteAgent(_req: IncomingMessage, res: ServerResponse, agentId: string): void {
|
|
const index = agents.findIndex((a) => a.id === agentId);
|
|
if (index === -1) {
|
|
sendError(res, 'Agent not found', 404);
|
|
return;
|
|
}
|
|
agents.splice(index, 1);
|
|
addAuditLogEntry({
|
|
action: 'agent.deleted',
|
|
actor: 'system',
|
|
result: 'success',
|
|
details: { agentId },
|
|
});
|
|
sendJson(res, { ok: true });
|
|
}
|
|
|
|
function handleGetHands(_req: IncomingMessage, res: ServerResponse): void {
|
|
sendJson(res, { hands });
|
|
}
|
|
|
|
function handleGetHand(_req: IncomingMessage, res: ServerResponse, name: string): void {
|
|
const hand = hands.find((h) => h.name === name);
|
|
if (!hand) {
|
|
sendError(res, 'Hand not found', 404);
|
|
return;
|
|
}
|
|
sendJson(res, { name: hand.name, description: hand.description, config: hand.config || {} });
|
|
}
|
|
|
|
function handleTriggerHand(_req: IncomingMessage, res: ServerResponse, name: string): void {
|
|
const hand = hands.find((h) => h.name === name);
|
|
if (!hand) {
|
|
sendError(res, 'Hand not found', 404);
|
|
return;
|
|
}
|
|
|
|
const runId = `run-${name}-${Date.now()}`;
|
|
const needsApproval = name === 'lead' || name === 'twitter'; // Some hands need approval
|
|
|
|
const status = needsApproval ? 'needs_approval' : 'running';
|
|
handRuns.set(runId, { status, startedAt: new Date().toISOString() });
|
|
|
|
// Update hand status
|
|
const handIndex = hands.findIndex((h) => h.name === name);
|
|
hands[handIndex] = { ...hand, status: needsApproval ? 'needs_approval' : 'running' };
|
|
|
|
addAuditLogEntry({
|
|
action: 'hand.triggered',
|
|
actor: 'system',
|
|
result: 'success',
|
|
details: { handName: name, runId, needsApproval },
|
|
});
|
|
|
|
sendJson(res, { runId, status });
|
|
|
|
// Simulate async events for running hands
|
|
if (!needsApproval) {
|
|
setTimeout(() => {
|
|
simulateStreamEvent('hand', {
|
|
handName: name,
|
|
runId,
|
|
status: 'running',
|
|
phase: 'start',
|
|
});
|
|
}, 100);
|
|
}
|
|
}
|
|
|
|
function handleApproveHand(_req: IncomingMessage, res: ServerResponse, name: string, runId: string): void {
|
|
parseBody(_req, (body) => {
|
|
const run = handRuns.get(runId);
|
|
if (!run) {
|
|
sendError(res, 'Run not found', 404);
|
|
return;
|
|
}
|
|
|
|
const approved = body.approved === true;
|
|
run.status = approved ? 'running' : 'cancelled';
|
|
handRuns.set(runId, run);
|
|
|
|
addAuditLogEntry({
|
|
action: approved ? 'hand.approved' : 'hand.rejected',
|
|
actor: 'operator',
|
|
result: 'success',
|
|
details: { handName: name, runId, reason: body.reason },
|
|
});
|
|
|
|
sendJson(res, { status: run.status });
|
|
|
|
if (approved) {
|
|
simulateStreamEvent('hand', {
|
|
handName: name,
|
|
runId,
|
|
status: 'running',
|
|
phase: 'start',
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function handleCancelHand(_req: IncomingMessage, res: ServerResponse, name: string, runId: string): void {
|
|
const run = handRuns.get(runId);
|
|
if (!run) {
|
|
sendError(res, 'Run not found', 404);
|
|
return;
|
|
}
|
|
|
|
run.status = 'cancelled';
|
|
handRuns.set(runId, run);
|
|
|
|
addAuditLogEntry({
|
|
action: 'hand.cancelled',
|
|
actor: 'operator',
|
|
result: 'success',
|
|
details: { handName: name, runId },
|
|
});
|
|
|
|
sendJson(res, { status: 'cancelled' });
|
|
}
|
|
|
|
function handleGetHandRuns(_req: IncomingMessage, res: ServerResponse, name: string): void {
|
|
const runs = Array.from(handRuns.entries())
|
|
.filter(([runId]) => runId.startsWith(`run-${name}-`))
|
|
.map(([runId, data]) => ({
|
|
runId,
|
|
status: data.status,
|
|
startedAt: data.startedAt,
|
|
}));
|
|
sendJson(res, { runs });
|
|
}
|
|
|
|
function handleGetWorkflows(_req: IncomingMessage, res: ServerResponse): void {
|
|
sendJson(res, { workflows });
|
|
}
|
|
|
|
function handleGetWorkflow(_req: IncomingMessage, res: ServerResponse, id: string): void {
|
|
const workflow = workflows.find((w) => w.id === id);
|
|
if (!workflow) {
|
|
sendError(res, 'Workflow not found', 404);
|
|
return;
|
|
}
|
|
sendJson(res, {
|
|
id: workflow.id,
|
|
name: workflow.name,
|
|
steps: Array.from({ length: workflow.steps }, (_, i) => ({
|
|
id: `step-${i + 1}`,
|
|
name: `Step ${i + 1}`,
|
|
})),
|
|
});
|
|
}
|
|
|
|
function handleExecuteWorkflow(_req: IncomingMessage, res: ServerResponse, id: string): void {
|
|
const workflow = workflows.find((w) => w.id === id);
|
|
if (!workflow) {
|
|
sendError(res, 'Workflow not found', 404);
|
|
return;
|
|
}
|
|
|
|
parseBody(_req, (body) => {
|
|
const runId = `wf-run-${id}-${Date.now()}`;
|
|
workflowRuns.set(runId, { status: 'running', step: 'step-1' });
|
|
|
|
addAuditLogEntry({
|
|
action: 'workflow.started',
|
|
actor: 'system',
|
|
result: 'success',
|
|
details: { workflowId: id, runId, input: body },
|
|
});
|
|
|
|
sendJson(res, { runId, status: 'running' });
|
|
|
|
// Simulate workflow progress
|
|
setTimeout(() => {
|
|
simulateStreamEvent('workflow', {
|
|
workflowId: id,
|
|
runId,
|
|
status: 'running',
|
|
step: 'step-1',
|
|
workflowStatus: 'executing',
|
|
});
|
|
}, 100);
|
|
});
|
|
}
|
|
|
|
function handleGetWorkflowRun(_req: IncomingMessage, res: ServerResponse, workflowId: string, runId: string): void {
|
|
const run = workflowRuns.get(runId);
|
|
if (!run) {
|
|
sendError(res, 'Run not found', 404);
|
|
return;
|
|
}
|
|
sendJson(res, { status: run.status, step: run.step, result: run.result });
|
|
}
|
|
|
|
function handleCancelWorkflow(_req: IncomingMessage, res: ServerResponse, workflowId: string, runId: string): void {
|
|
const run = workflowRuns.get(runId);
|
|
if (!run) {
|
|
sendError(res, 'Run not found', 404);
|
|
return;
|
|
}
|
|
|
|
run.status = 'cancelled';
|
|
workflowRuns.set(runId, run);
|
|
|
|
addAuditLogEntry({
|
|
action: 'workflow.cancelled',
|
|
actor: 'operator',
|
|
result: 'success',
|
|
details: { workflowId, runId },
|
|
});
|
|
|
|
sendJson(res, { status: 'cancelled' });
|
|
}
|
|
|
|
function handleGetTriggers(_req: IncomingMessage, res: ServerResponse): void {
|
|
sendJson(res, { triggers });
|
|
}
|
|
|
|
function handleGetAuditLogs(req: IncomingMessage, res: ServerResponse): void {
|
|
const url = new URL(req.url || '/', `http://${host}`);
|
|
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
|
|
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
|
|
|
|
const paginatedLogs = auditLogs.slice(offset, offset + limit);
|
|
sendJson(res, { logs: paginatedLogs, total: auditLogs.length });
|
|
}
|
|
|
|
function handleGetSecurityStatus(_req: IncomingMessage, res: ServerResponse): void {
|
|
const enabledCount = securityLayers.filter((l) => l.enabled).length;
|
|
sendJson(res, {
|
|
layers: securityLayers,
|
|
enabledCount,
|
|
totalCount: securityLayers.length,
|
|
securityLevel: calculateSecurityLevel(enabledCount, securityLayers.length),
|
|
});
|
|
}
|
|
|
|
function handleGetCapabilities(_req: IncomingMessage, res: ServerResponse): void {
|
|
sendJson(res, {
|
|
capabilities: [
|
|
'operator.read',
|
|
'operator.write',
|
|
'operator.admin',
|
|
'operator.approvals',
|
|
'operator.pairing',
|
|
],
|
|
});
|
|
}
|
|
|
|
function handleChat(req: IncomingMessage, res: ServerResponse): void {
|
|
parseBody(req, (body) => {
|
|
const runId = `chat-${Date.now()}`;
|
|
const sessionId = body.session_id || `session-${Date.now()}`;
|
|
|
|
addAuditLogEntry({
|
|
action: 'chat.message',
|
|
actor: body.agent_id || 'default',
|
|
result: 'success',
|
|
details: { runId, sessionId, messageLength: body.message?.length || 0 },
|
|
});
|
|
|
|
sendJson(res, { runId, sessionId });
|
|
|
|
// Simulate streaming events
|
|
setTimeout(() => {
|
|
simulateStreamEvent('agent', {
|
|
stream: 'assistant',
|
|
delta: 'Hello',
|
|
runId,
|
|
});
|
|
setTimeout(() => {
|
|
simulateStreamEvent('agent', {
|
|
stream: 'assistant',
|
|
delta: '! How can I help you today?',
|
|
runId,
|
|
});
|
|
setTimeout(() => {
|
|
simulateStreamEvent('agent', {
|
|
stream: 'lifecycle',
|
|
phase: 'end',
|
|
runId,
|
|
});
|
|
}, 100);
|
|
}, 100);
|
|
}, 100);
|
|
});
|
|
}
|
|
|
|
function handleGetModels(_req: IncomingMessage, res: ServerResponse): void {
|
|
sendJson(res, {
|
|
models: [
|
|
{ id: 'gpt-4', name: 'GPT-4', provider: 'openai' },
|
|
{ id: 'gpt-3.5-turbo', name: 'GPT-3.5 Turbo', provider: 'openai' },
|
|
{ id: 'claude-3-sonnet', name: 'Claude 3 Sonnet', provider: 'anthropic' },
|
|
{ id: 'deepseek-chat', name: 'DeepSeek Chat', provider: 'deepseek' },
|
|
],
|
|
});
|
|
}
|
|
|
|
function handleGetConfig(_req: IncomingMessage, res: ServerResponse): void {
|
|
sendJson(res, {
|
|
server: { host: '127.0.0.1', port: actualPort },
|
|
agent: { default_model: 'gpt-4' },
|
|
llm: {
|
|
providers: [
|
|
{ name: 'openai', api_key: '${OPENAI_API_KEY}' },
|
|
],
|
|
},
|
|
});
|
|
}
|
|
|
|
function handleGetQuickConfig(_req: IncomingMessage, res: ServerResponse): void {
|
|
sendJson(res, {
|
|
quickConfig: {
|
|
agentName: 'ZCLAW',
|
|
agentRole: 'assistant',
|
|
userName: 'User',
|
|
userRole: 'developer',
|
|
theme: 'dark',
|
|
showToolCalls: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
function handleGetWorkspace(_req: IncomingMessage, res: ServerResponse): void {
|
|
sendJson(res, {
|
|
path: '~/.openfang/workspaces/default',
|
|
resolvedPath: '/home/user/.openfang/workspaces/default',
|
|
exists: true,
|
|
fileCount: 42,
|
|
totalSize: 1024 * 1024 * 5, // 5MB
|
|
});
|
|
}
|
|
|
|
function handleGetSkills(_req: IncomingMessage, res: ServerResponse): void {
|
|
sendJson(res, {
|
|
skills: [
|
|
{ id: 'skill-001', name: 'Code Review', path: '/skills/code-review', source: 'builtin' },
|
|
{ id: 'skill-002', name: 'Translation', path: '/skills/translation', source: 'builtin' },
|
|
{ id: 'skill-003', name: 'Research', path: '/skills/research', source: 'extra' },
|
|
],
|
|
extraDirs: ['/extra/skills'],
|
|
});
|
|
}
|
|
|
|
function handleGetChannels(_req: IncomingMessage, res: ServerResponse): void {
|
|
sendJson(res, {
|
|
channels: [
|
|
{ id: 'feishu', type: 'feishu', label: 'Feishu', status: 'active', accounts: 1 },
|
|
],
|
|
});
|
|
}
|
|
|
|
function handleGetStatsUsage(_req: IncomingMessage, res: ServerResponse): void {
|
|
sendJson(res, {
|
|
totalSessions: 10,
|
|
totalMessages: 150,
|
|
totalTokens: 50000,
|
|
byModel: {
|
|
'gpt-4': { messages: 100, inputTokens: 30000, outputTokens: 15000 },
|
|
'claude-3-sonnet': { messages: 50, inputTokens: 4000, outputTokens: 1000 },
|
|
},
|
|
});
|
|
}
|
|
|
|
function handleGetPluginsStatus(_req: IncomingMessage, res: ServerResponse): void {
|
|
sendJson(res, {
|
|
plugins: [
|
|
{ id: 'feishu', name: 'Feishu Integration', status: 'active' },
|
|
{ id: 'chinese-models', name: 'Chinese Models', status: 'active' },
|
|
],
|
|
});
|
|
}
|
|
|
|
// === HTTP Request Handler ===
|
|
|
|
function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
|
// Add CORS headers
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(204);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
const url = new URL(req.url || '/', `http://${host}`);
|
|
const path = url.pathname;
|
|
|
|
// Route matching
|
|
const routes: Array<{
|
|
method: string | string[];
|
|
pattern: RegExp;
|
|
handler: (req: IncomingMessage, res: ServerResponse, ...matches: string[]) => void;
|
|
}> = [
|
|
{ method: 'GET', pattern: /^\/api\/health$/, handler: handleHealth },
|
|
{ method: 'GET', pattern: /^\/api\/agents$/, handler: handleGetAgents },
|
|
{ method: 'POST', pattern: /^\/api\/agents$/, handler: handleCreateAgent },
|
|
{ method: 'PUT', pattern: /^\/api\/agents\/([^/]+)$/, handler: handleUpdateAgent },
|
|
{ method: 'DELETE', pattern: /^\/api\/agents\/([^/]+)$/, handler: handleDeleteAgent },
|
|
{ method: 'GET', pattern: /^\/api\/hands$/, handler: handleGetHands },
|
|
{ method: 'GET', pattern: /^\/api\/hands\/([^/]+)$/, handler: handleGetHand },
|
|
{ method: 'POST', pattern: /^\/api\/hands\/([^/]+)\/trigger$/, handler: handleTriggerHand },
|
|
{ method: 'POST', pattern: /^\/api\/hands\/([^/]+)\/runs\/([^/]+)\/approve$/, handler: handleApproveHand },
|
|
{ method: 'POST', pattern: /^\/api\/hands\/([^/]+)\/runs\/([^/]+)\/cancel$/, handler: handleCancelHand },
|
|
{ method: 'GET', pattern: /^\/api\/hands\/([^/]+)\/runs$/, handler: handleGetHandRuns },
|
|
{ method: 'GET', pattern: /^\/api\/workflows$/, handler: handleGetWorkflows },
|
|
{ method: 'GET', pattern: /^\/api\/workflows\/([^/]+)$/, handler: handleGetWorkflow },
|
|
{ method: 'POST', pattern: /^\/api\/workflows\/([^/]+)\/execute$/, handler: handleExecuteWorkflow },
|
|
{ method: 'GET', pattern: /^\/api\/workflows\/([^/]+)\/runs\/([^/]+)$/, handler: handleGetWorkflowRun },
|
|
{ method: 'POST', pattern: /^\/api\/workflows\/([^/]+)\/runs\/([^/]+)\/cancel$/, handler: handleCancelWorkflow },
|
|
{ method: 'GET', pattern: /^\/api\/triggers$/, handler: handleGetTriggers },
|
|
{ method: 'GET', pattern: /^\/api\/audit\/logs$/, handler: handleGetAuditLogs },
|
|
{ method: 'GET', pattern: /^\/api\/security\/status$/, handler: handleGetSecurityStatus },
|
|
{ method: 'GET', pattern: /^\/api\/capabilities$/, handler: handleGetCapabilities },
|
|
{ method: 'POST', pattern: /^\/api\/chat$/, handler: handleChat },
|
|
{ method: 'GET', pattern: /^\/api\/models$/, handler: handleGetModels },
|
|
{ method: 'GET', pattern: /^\/api\/config$/, handler: handleGetConfig },
|
|
{ method: 'GET', pattern: /^\/api\/config\/quick$/, handler: handleGetQuickConfig },
|
|
{ method: 'GET', pattern: /^\/api\/workspace$/, handler: handleGetWorkspace },
|
|
{ method: 'GET', pattern: /^\/api\/skills$/, handler: handleGetSkills },
|
|
{ method: 'GET', pattern: /^\/api\/channels$/, handler: handleGetChannels },
|
|
{ method: 'GET', pattern: /^\/api\/stats\/usage$/, handler: handleGetStatsUsage },
|
|
{ method: 'GET', pattern: /^\/api\/plugins\/status$/, handler: handleGetPluginsStatus },
|
|
];
|
|
|
|
for (const route of routes) {
|
|
const methods = Array.isArray(route.method) ? route.method : [route.method];
|
|
if (!methods.includes(req.method || 'GET')) continue;
|
|
|
|
const match = path.match(route.pattern);
|
|
if (match) {
|
|
try {
|
|
route.handler(req, res, ...match.slice(1));
|
|
} catch (error) {
|
|
console.error('[MockServer] Handler error:', error);
|
|
sendError(res, 'Internal server error', 500);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// No route matched
|
|
sendError(res, 'Not found', 404);
|
|
}
|
|
|
|
// === WebSocket Handler ===
|
|
|
|
function handleWebSocket(ws: WebSocket): void {
|
|
connectedClients.add(ws);
|
|
|
|
ws.on('message', (data: RawData) => {
|
|
try {
|
|
const message = JSON.parse(data.toString());
|
|
|
|
if (message.type === 'req') {
|
|
handleWsRequest(ws, message);
|
|
}
|
|
} catch (error) {
|
|
console.error('[MockServer] WebSocket message parse error:', error);
|
|
}
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
connectedClients.delete(ws);
|
|
});
|
|
|
|
ws.on('error', (error) => {
|
|
console.error('[MockServer] WebSocket error:', error);
|
|
connectedClients.delete(ws);
|
|
});
|
|
|
|
// Send challenge after a short delay
|
|
setTimeout(() => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
const nonce = `nonce-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
ws.send(JSON.stringify({
|
|
type: 'event',
|
|
event: 'connect.challenge',
|
|
payload: { nonce },
|
|
}));
|
|
}
|
|
}, challengeDelay);
|
|
}
|
|
|
|
function handleWsRequest(ws: WebSocket, message: { id: string; method: string; params?: unknown }): void {
|
|
if (message.method === 'connect') {
|
|
// Handle connect handshake
|
|
setTimeout(() => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({
|
|
type: 'res',
|
|
id: message.id,
|
|
ok: true,
|
|
payload: {
|
|
sessionId: `session-${Date.now()}`,
|
|
protocolVersion: 3,
|
|
serverVersion: version,
|
|
},
|
|
}));
|
|
}
|
|
}, connectionDelay);
|
|
} else if (message.method === 'health') {
|
|
ws.send(JSON.stringify({
|
|
type: 'res',
|
|
id: message.id,
|
|
ok: true,
|
|
payload: { version, status: 'ok' },
|
|
}));
|
|
} else if (message.method === 'status') {
|
|
ws.send(JSON.stringify({
|
|
type: 'res',
|
|
id: message.id,
|
|
ok: true,
|
|
payload: {
|
|
uptime: Math.floor(process.uptime()),
|
|
connections: connectedClients.size,
|
|
version,
|
|
},
|
|
}));
|
|
} else {
|
|
// Unknown method
|
|
ws.send(JSON.stringify({
|
|
type: 'res',
|
|
id: message.id,
|
|
ok: false,
|
|
error: { code: 'METHOD_NOT_FOUND', message: `Unknown method: ${message.method}` },
|
|
}));
|
|
}
|
|
}
|
|
|
|
// === Helper Functions ===
|
|
|
|
function sendJson(res: ServerResponse, data: unknown, statusCode = 200): void {
|
|
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(data));
|
|
}
|
|
|
|
function sendError(res: ServerResponse, message: string, statusCode = 500): void {
|
|
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: { message, code: statusCode } }));
|
|
}
|
|
|
|
function parseBody<T>(req: IncomingMessage, callback: (body: T) => void): void {
|
|
let body = '';
|
|
req.on('data', (chunk) => {
|
|
body += chunk.toString();
|
|
});
|
|
req.on('end', () => {
|
|
try {
|
|
const parsed = body ? JSON.parse(body) : {};
|
|
callback(parsed);
|
|
} catch {
|
|
// Send error response if we have access to res
|
|
// This is a simplified version; in real use, pass res to this function
|
|
console.error('[MockServer] Failed to parse request body');
|
|
}
|
|
});
|
|
}
|
|
|
|
function simulateStreamEvent(event: string, payload: unknown): void {
|
|
const message = JSON.stringify({ type: 'event', event, payload });
|
|
wsServer?.clients.forEach((ws) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(message);
|
|
}
|
|
});
|
|
}
|
|
|
|
function addAuditLogEntry(entry: Omit<MockAuditLogEntry, 'id' | 'timestamp'>): void {
|
|
const logEntry: MockAuditLogEntry = {
|
|
id: `log-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
timestamp: new Date().toISOString(),
|
|
...entry,
|
|
};
|
|
auditLogs.unshift(logEntry); // Add to beginning for reverse chronological order
|
|
// Keep only last 1000 logs
|
|
if (auditLogs.length > 1000) {
|
|
auditLogs = auditLogs.slice(0, 1000);
|
|
}
|
|
}
|
|
|
|
function calculateSecurityLevel(enabledCount: number, totalCount: number): 'critical' | 'high' | 'medium' | 'low' {
|
|
if (totalCount === 0) return 'low';
|
|
const ratio = enabledCount / totalCount;
|
|
if (ratio >= 0.875) return 'critical'; // 14-16 layers
|
|
if (ratio >= 0.625) return 'high'; // 10-13 layers
|
|
if (ratio >= 0.375) return 'medium'; // 6-9 layers
|
|
return 'low'; // 0-5 layers
|
|
}
|
|
|
|
// === Public API ===
|
|
|
|
return {
|
|
start: () => {
|
|
return new Promise((resolve, reject) => {
|
|
httpServer = createServer(handleRequest);
|
|
|
|
httpServer.on('error', (error: NodeJS.ErrnoException) => {
|
|
if (error.code === 'EADDRINUSE') {
|
|
reject(new Error(`Port ${port} is already in use`));
|
|
} else {
|
|
reject(error);
|
|
}
|
|
});
|
|
|
|
httpServer.listen(port, host, () => {
|
|
const address = httpServer?.address();
|
|
actualPort = typeof address === 'object' && address ? address.port : port;
|
|
|
|
// Set up WebSocket server
|
|
wsServer = new WebSocketServer({ server: httpServer, path: '/ws' });
|
|
wsServer.on('connection', handleWebSocket);
|
|
|
|
console.log(`[MockServer] Started on http://${host}:${actualPort}`);
|
|
console.log(`[MockServer] WebSocket available at ws://${host}:${actualPort}/ws`);
|
|
resolve();
|
|
});
|
|
});
|
|
},
|
|
|
|
stop: () => {
|
|
return new Promise((resolve) => {
|
|
// Close all WebSocket connections
|
|
if (wsServer) {
|
|
wsServer.clients.forEach((ws) => {
|
|
ws.close(1000, 'Server shutting down');
|
|
});
|
|
wsServer.close(() => {
|
|
wsServer = null;
|
|
});
|
|
}
|
|
|
|
if (httpServer) {
|
|
httpServer.close(() => {
|
|
console.log('[MockServer] Stopped');
|
|
httpServer = null;
|
|
resolve();
|
|
});
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
},
|
|
|
|
getPort: () => actualPort,
|
|
getWsUrl: () => `ws://${host}:${actualPort}/ws`,
|
|
getHttpUrl: () => `http://${host}:${actualPort}`,
|
|
|
|
reset: () => {
|
|
hands = [...DEFAULT_HANDS];
|
|
workflows = [...DEFAULT_WORKFLOWS];
|
|
triggers = [...DEFAULT_TRIGGERS];
|
|
agents = [...DEFAULT_AGENTS];
|
|
securityLayers = [...DEFAULT_SECURITY_LAYERS];
|
|
auditLogs = [];
|
|
handRuns.clear();
|
|
workflowRuns.clear();
|
|
},
|
|
|
|
setHands: (newHands: MockHand[]) => {
|
|
hands = newHands;
|
|
},
|
|
|
|
setWorkflows: (newWorkflows: MockWorkflow[]) => {
|
|
workflows = newWorkflows;
|
|
},
|
|
|
|
setTriggers: (newTriggers: MockTrigger[]) => {
|
|
triggers = newTriggers;
|
|
},
|
|
|
|
setAgents: (newAgents: MockAgent[]) => {
|
|
agents = newAgents;
|
|
},
|
|
|
|
setSecurityLayers: (newLayers: MockSecurityLayer[]) => {
|
|
securityLayers = newLayers;
|
|
},
|
|
|
|
addAuditLog: (entry: Omit<MockAuditLogEntry, 'id' | 'timestamp'>) => {
|
|
addAuditLogEntry(entry);
|
|
},
|
|
|
|
simulateStreamEvent: (event: string, payload: unknown) => {
|
|
const message = JSON.stringify({ type: 'event', event, payload });
|
|
wsServer?.clients.forEach((ws) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(message);
|
|
}
|
|
});
|
|
},
|
|
|
|
getConnectedClients: () => connectedClients.size,
|
|
};
|
|
}
|
|
|
|
// === Export for CommonJS compatibility ===
|
|
|
|
export default createOpenFangMockServer;
|