diff --git a/README.md b/README.md index 237d3fd..dc4ab10 100644 --- a/README.md +++ b/README.md @@ -342,7 +342,9 @@ See [`.env.example`](.env.example) for the complete list. Key variables: | `AUTH_PASS` | No | Initial admin password | | `AUTH_PASS_B64` | No | Base64-encoded admin password (overrides `AUTH_PASS` if set) | | `API_KEY` | No | API key for headless access | -| `OPENCLAW_HOME` | Yes* | Path to `.openclaw` directory | +| `OPENCLAW_CONFIG_PATH` | Yes* | Absolute path to `openclaw.json` (preferred) | +| `OPENCLAW_STATE_DIR` | Yes* | OpenClaw state root (default: `~/.openclaw`) | +| `OPENCLAW_HOME` | No | Legacy alias for state dir (fallback if `OPENCLAW_STATE_DIR` unset) | | `OPENCLAW_GATEWAY_HOST` | No | Gateway host (default: `127.0.0.1`) | | `OPENCLAW_GATEWAY_PORT` | No | Gateway WebSocket port (default: `18789`) | | `OPENCLAW_GATEWAY_TOKEN` | No | Server-side gateway auth token | @@ -354,10 +356,10 @@ See [`.env.example`](.env.example) for the complete list. Key variables: | `MC_TRUSTED_PROXIES` | No | Comma-separated trusted proxy IPs for XFF parsing | | `MC_ALLOWED_HOSTS` | No | Host allowlist for production | -*Memory browser, log viewer, and gateway config require `OPENCLAW_HOME`. +*Memory browser, log viewer, and gateway config require OpenClaw config/state resolution (`OPENCLAW_CONFIG_PATH` and/or `OPENCLAW_STATE_DIR`). > **Memory Browser note:** OpenClaw does not store agent memory markdown files under -> `$OPENCLAW_HOME/memory/` — that directory does not exist by default. Agent memory lives +> `$OPENCLAW_STATE_DIR/memory/` — that directory does not exist by default. Agent memory lives > in each agent's workspace (e.g. `~/clawd-agents/{agent}/memory/`). Set > `OPENCLAW_MEMORY_DIR` to your agents root directory to make the Memory Browser show > daily logs, `MEMORY.md`, and other markdown files: @@ -399,7 +401,7 @@ pnpm install --frozen-lockfile pnpm build # Run -OPENCLAW_HOME=/path/to/.openclaw pnpm start +OPENCLAW_CONFIG_PATH=/path/to/.openclaw/openclaw.json OPENCLAW_STATE_DIR=/path/to/.openclaw pnpm start ``` Network access is restricted by default in production. Set `MC_ALLOWED_HOSTS` (comma-separated) or `MC_ALLOW_ANY_HOST=1` to control access. diff --git a/package.json b/package.json index 7cb7289..25e8136 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,9 @@ "test:watch": "vitest", "test:ui": "vitest --ui", "test:e2e": "playwright test", + "test:e2e:openclaw:local": "E2E_GATEWAY_EXPECTED=0 playwright test -c playwright.openclaw.local.config.ts", + "test:e2e:openclaw:gateway": "E2E_GATEWAY_EXPECTED=1 playwright test -c playwright.openclaw.gateway.config.ts", + "test:e2e:openclaw": "pnpm test:e2e:openclaw:local && pnpm test:e2e:openclaw:gateway", "test:all": "pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm test:e2e", "quality:gate": "pnpm test:all" }, @@ -77,4 +80,4 @@ "better-sqlite3" ] } -} \ No newline at end of file +} diff --git a/playwright.openclaw.gateway.config.ts b/playwright.openclaw.gateway.config.ts new file mode 100644 index 0000000..8df49dc --- /dev/null +++ b/playwright.openclaw.gateway.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: 'tests', + testMatch: /openclaw-harness\.spec\.ts/, + timeout: 60_000, + expect: { + timeout: 10_000, + }, + fullyParallel: false, + workers: 1, + reporter: [['list']], + use: { + baseURL: 'http://127.0.0.1:3005', + trace: 'retain-on-failure', + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], + webServer: { + command: 'node scripts/e2e-openclaw/start-e2e-server.mjs --mode=gateway', + url: 'http://127.0.0.1:3005', + reuseExistingServer: false, + timeout: 120_000, + }, +}) diff --git a/playwright.openclaw.local.config.ts b/playwright.openclaw.local.config.ts new file mode 100644 index 0000000..ec80cf2 --- /dev/null +++ b/playwright.openclaw.local.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: 'tests', + testMatch: /openclaw-harness\.spec\.ts/, + timeout: 60_000, + expect: { + timeout: 10_000, + }, + fullyParallel: false, + workers: 1, + reporter: [['list']], + use: { + baseURL: 'http://127.0.0.1:3005', + trace: 'retain-on-failure', + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], + webServer: { + command: 'node scripts/e2e-openclaw/start-e2e-server.mjs --mode=local', + url: 'http://127.0.0.1:3005', + reuseExistingServer: false, + timeout: 120_000, + }, +}) diff --git a/scripts/e2e-openclaw/bin/clawdbot b/scripts/e2e-openclaw/bin/clawdbot new file mode 100755 index 0000000..0aa1e80 --- /dev/null +++ b/scripts/e2e-openclaw/bin/clawdbot @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${1:-}" == "--version" ]]; then + echo "clawdbot 2026.3.2" + exit 0 +fi + +if [[ "${1:-}" == "-c" ]]; then + cmd="${2:-}" + if [[ "$cmd" == sessions_spawn* ]]; then + echo "Session created: mock-clawdbot-session" + exit 0 + fi + echo "ok" + exit 0 +fi + +echo "clawdbot mock: unsupported args: $*" >&2 +exit 0 diff --git a/scripts/e2e-openclaw/bin/openclaw b/scripts/e2e-openclaw/bin/openclaw new file mode 100755 index 0000000..38c4a1c --- /dev/null +++ b/scripts/e2e-openclaw/bin/openclaw @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${1:-}" == "--version" ]]; then + echo "openclaw 2026.3.2" + exit 0 +fi + +if [[ "${1:-}" == "-c" ]]; then + cmd="${2:-}" + if [[ "$cmd" == sessions_spawn* ]]; then + echo "Session created: mock-openclaw-session" + exit 0 + fi + echo "ok" + exit 0 +fi + +echo "openclaw mock: unsupported args: $*" >&2 +exit 0 diff --git a/scripts/e2e-openclaw/mock-gateway.mjs b/scripts/e2e-openclaw/mock-gateway.mjs new file mode 100755 index 0000000..e3ff063 --- /dev/null +++ b/scripts/e2e-openclaw/mock-gateway.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node +import http from 'node:http' +import { WebSocketServer } from 'ws' + +const host = process.env.OPENCLAW_GATEWAY_HOST || '127.0.0.1' +const port = Number(process.env.OPENCLAW_GATEWAY_PORT || 18789) + +const server = http.createServer((req, res) => { + if (!req.url) { + res.writeHead(404) + res.end() + return + } + + if (req.url === '/health') { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ ok: true, service: 'openclaw-mock-gateway' })) + return + } + + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ ok: true })) +}) + +const wss = new WebSocketServer({ noServer: true }) + +wss.on('connection', (ws) => { + ws.send(JSON.stringify({ type: 'status', connected: true, source: 'mock-gateway' })) + ws.on('message', (raw) => { + const text = raw.toString() + if (text.includes('ping')) { + ws.send(JSON.stringify({ type: 'pong', ts: Date.now() })) + return + } + ws.send(JSON.stringify({ type: 'event', message: 'ack', raw: text })) + }) +}) + +server.on('upgrade', (req, socket, head) => { + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit('connection', ws, req) + }) +}) + +server.listen(port, host, () => { + process.stdout.write(`[openclaw-mock-gateway] listening on ${host}:${port}\n`) +}) + +function shutdown() { + wss.clients.forEach((client) => { + try { + client.close() + } catch { + // noop + } + }) + server.close(() => process.exit(0)) +} + +process.on('SIGINT', shutdown) +process.on('SIGTERM', shutdown) diff --git a/scripts/e2e-openclaw/start-e2e-server.mjs b/scripts/e2e-openclaw/start-e2e-server.mjs new file mode 100755 index 0000000..35703b0 --- /dev/null +++ b/scripts/e2e-openclaw/start-e2e-server.mjs @@ -0,0 +1,99 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +const modeArg = process.argv.find((arg) => arg.startsWith('--mode=')) +const mode = modeArg ? modeArg.split('=')[1] : 'local' +if (mode !== 'local' && mode !== 'gateway') { + process.stderr.write(`Invalid mode: ${mode}\n`) + process.exit(1) +} + +const repoRoot = process.cwd() +const fixtureSource = path.join(repoRoot, 'tests', 'fixtures', 'openclaw') +const runtimeRoot = path.join(repoRoot, '.tmp', 'e2e-openclaw', mode) +const dataDir = path.join(runtimeRoot, 'data') +const mockBinDir = path.join(repoRoot, 'scripts', 'e2e-openclaw', 'bin') + +fs.rmSync(runtimeRoot, { recursive: true, force: true }) +fs.mkdirSync(runtimeRoot, { recursive: true }) +fs.mkdirSync(dataDir, { recursive: true }) +fs.cpSync(fixtureSource, runtimeRoot, { recursive: true }) + +const gatewayHost = '127.0.0.1' +const gatewayPort = '18789' + +const baseEnv = { + ...process.env, + API_KEY: process.env.API_KEY || 'test-api-key-e2e-12345', + AUTH_USER: process.env.AUTH_USER || 'admin', + AUTH_PASS: process.env.AUTH_PASS || 'admin', + MC_DISABLE_RATE_LIMIT: '1', + MISSION_CONTROL_DATA_DIR: dataDir, + MISSION_CONTROL_DB_PATH: path.join(dataDir, 'mission-control.db'), + OPENCLAW_STATE_DIR: runtimeRoot, + OPENCLAW_CONFIG_PATH: path.join(runtimeRoot, 'openclaw.json'), + OPENCLAW_GATEWAY_HOST: gatewayHost, + OPENCLAW_GATEWAY_PORT: gatewayPort, + OPENCLAW_BIN: path.join(mockBinDir, 'openclaw'), + CLAWDBOT_BIN: path.join(mockBinDir, 'clawdbot'), + PATH: `${mockBinDir}:${process.env.PATH || ''}`, + E2E_GATEWAY_EXPECTED: mode === 'gateway' ? '1' : '0', +} + +const children = [] + +if (mode === 'gateway') { + const gw = spawn('node', ['scripts/e2e-openclaw/mock-gateway.mjs'], { + cwd: repoRoot, + env: baseEnv, + stdio: 'inherit', + }) + children.push(gw) +} + +const standaloneServerPath = path.join(repoRoot, '.next', 'standalone', 'server.js') +const app = fs.existsSync(standaloneServerPath) + ? spawn('node', [standaloneServerPath], { + cwd: repoRoot, + env: { + ...baseEnv, + HOSTNAME: '127.0.0.1', + PORT: '3005', + }, + stdio: 'inherit', + }) + : spawn('pnpm', ['start'], { + cwd: repoRoot, + env: baseEnv, + stdio: 'inherit', + }) +children.push(app) + +function shutdown(signal = 'SIGTERM') { + for (const child of children) { + if (!child.killed) { + try { + child.kill(signal) + } catch { + // noop + } + } + } +} + +process.on('SIGINT', () => { + shutdown('SIGINT') + process.exit(130) +}) +process.on('SIGTERM', () => { + shutdown('SIGTERM') + process.exit(143) +}) + +app.on('exit', (code) => { + shutdown('SIGTERM') + process.exit(code ?? 0) +}) diff --git a/src/app/api/agents/[id]/soul/route.ts b/src/app/api/agents/[id]/soul/route.ts index 061472a..5e506ad 100644 --- a/src/app/api/agents/[id]/soul/route.ts +++ b/src/app/api/agents/[id]/soul/route.ts @@ -1,12 +1,18 @@ import { NextRequest, NextResponse } from 'next/server'; import { getDatabase, db_helpers } from '@/lib/db'; import { readFileSync, existsSync, readdirSync, writeFileSync, mkdirSync } from 'fs'; -import { join, dirname } from 'path'; +import { join, dirname, isAbsolute, resolve } from 'path'; import { config } from '@/lib/config'; import { resolveWithin } from '@/lib/paths'; import { requireRole } from '@/lib/auth'; import { logger } from '@/lib/logger'; +function resolveAgentWorkspacePath(workspace: string): string { + if (isAbsolute(workspace)) return resolve(workspace) + if (!config.openclawStateDir) throw new Error('OPENCLAW_STATE_DIR not configured') + return resolveWithin(config.openclawStateDir, workspace) +} + /** * GET /api/agents/[id]/soul - Get agent's SOUL content */ @@ -41,8 +47,8 @@ export async function GET( try { const agentConfig = agent.config ? JSON.parse(agent.config) : {} - if (agentConfig.workspace && config.openclawHome) { - const safeWorkspace = resolveWithin(config.openclawHome, agentConfig.workspace) + if (agentConfig.workspace) { + const safeWorkspace = resolveAgentWorkspacePath(agentConfig.workspace) const safeSoulPath = resolveWithin(safeWorkspace, 'soul.md') if (existsSync(safeSoulPath)) { soulContent = readFileSync(safeSoulPath, 'utf-8') @@ -157,8 +163,8 @@ export async function PUT( let savedToWorkspace = false try { const agentConfig = agent.config ? JSON.parse(agent.config) : {} - if (agentConfig.workspace && config.openclawHome) { - const safeWorkspace = resolveWithin(config.openclawHome, agentConfig.workspace) + if (agentConfig.workspace) { + const safeWorkspace = resolveAgentWorkspacePath(agentConfig.workspace) const safeSoulPath = resolveWithin(safeWorkspace, 'soul.md') mkdirSync(dirname(safeSoulPath), { recursive: true }) writeFileSync(safeSoulPath, newSoulContent || '', 'utf-8') diff --git a/src/app/api/cron/route.ts b/src/app/api/cron/route.ts index e25ccd4..8973ab8 100644 --- a/src/app/api/cron/route.ts +++ b/src/app/api/cron/route.ts @@ -67,9 +67,9 @@ interface OpenClawCronFile { } function getCronFilePath(): string { - const openclawHome = config.openclawHome - if (!openclawHome) return '' - return path.join(openclawHome, 'cron', 'jobs.json') + const openclawStateDir = config.openclawStateDir + if (!openclawStateDir) return '' + return path.join(openclawStateDir, 'cron', 'jobs.json') } async function loadCronFile(): Promise { diff --git a/src/app/api/gateway-config/route.ts b/src/app/api/gateway-config/route.ts index 7fa1e43..9aac4d0 100644 --- a/src/app/api/gateway-config/route.ts +++ b/src/app/api/gateway-config/route.ts @@ -2,13 +2,11 @@ import { NextRequest, NextResponse } from 'next/server' import { requireRole } from '@/lib/auth' import { logAuditEvent } from '@/lib/db' import { config } from '@/lib/config' -import { join } from 'path' import { validateBody, gatewayConfigUpdateSchema } from '@/lib/validation' import { mutationLimiter } from '@/lib/rate-limit' function getConfigPath(): string | null { - if (!config.openclawHome) return null - return join(config.openclawHome, 'openclaw.json') + return config.openclawConfigPath || null } /** @@ -20,7 +18,7 @@ export async function GET(request: NextRequest) { const configPath = getConfigPath() if (!configPath) { - return NextResponse.json({ error: 'OPENCLAW_HOME not configured' }, { status: 404 }) + return NextResponse.json({ error: 'OPENCLAW_CONFIG_PATH not configured' }, { status: 404 }) } try { @@ -60,7 +58,7 @@ export async function PUT(request: NextRequest) { const configPath = getConfigPath() if (!configPath) { - return NextResponse.json({ error: 'OPENCLAW_HOME not configured' }, { status: 404 }) + return NextResponse.json({ error: 'OPENCLAW_CONFIG_PATH not configured' }, { status: 404 }) } const result = await validateBody(request, gatewayConfigUpdateSchema) diff --git a/src/app/api/integrations/route.ts b/src/app/api/integrations/route.ts index 1a1f4a7..921d1c9 100644 --- a/src/app/api/integrations/route.ts +++ b/src/app/api/integrations/route.ts @@ -108,8 +108,8 @@ function serializeEnv(lines: EnvLine[]): string { } function getEnvPath(): string | null { - if (!config.openclawHome) return null - return join(config.openclawHome, '.env') + if (!config.openclawStateDir) return null + return join(config.openclawStateDir, '.env') } async function readEnvFile(): Promise<{ lines: EnvLine[]; raw: string } | null> { @@ -184,7 +184,7 @@ export async function GET(request: NextRequest) { const envData = await readEnvFile() if (!envData) { - return NextResponse.json({ error: 'OPENCLAW_HOME not configured' }, { status: 404 }) + return NextResponse.json({ error: 'OPENCLAW_STATE_DIR not configured' }, { status: 404 }) } const envMap = new Map() @@ -261,7 +261,7 @@ export async function PUT(request: NextRequest) { const envData = await readEnvFile() if (!envData) { - return NextResponse.json({ error: 'OPENCLAW_HOME not configured' }, { status: 404 }) + return NextResponse.json({ error: 'OPENCLAW_STATE_DIR not configured' }, { status: 404 }) } const { lines } = envData @@ -324,7 +324,7 @@ export async function DELETE(request: NextRequest) { const envData = await readEnvFile() if (!envData) { - return NextResponse.json({ error: 'OPENCLAW_HOME not configured' }, { status: 404 }) + return NextResponse.json({ error: 'OPENCLAW_STATE_DIR not configured' }, { status: 404 }) } const removed: string[] = [] @@ -408,7 +408,7 @@ async function handleTest( const envData = await readEnvFile() if (!envData) { - return NextResponse.json({ error: 'OPENCLAW_HOME not configured' }, { status: 404 }) + return NextResponse.json({ error: 'OPENCLAW_STATE_DIR not configured' }, { status: 404 }) } const envMap = new Map() @@ -552,7 +552,7 @@ async function handlePull( // Write to .env const envData = await readEnvFile() if (!envData) { - return NextResponse.json({ error: 'OPENCLAW_HOME not configured' }, { status: 404 }) + return NextResponse.json({ error: 'OPENCLAW_STATE_DIR not configured' }, { status: 404 }) } const { lines } = envData @@ -621,7 +621,7 @@ async function handlePullAll( const envData = await readEnvFile() if (!envData) { - return NextResponse.json({ error: 'OPENCLAW_HOME not configured' }, { status: 404 }) + return NextResponse.json({ error: 'OPENCLAW_STATE_DIR not configured' }, { status: 404 }) } const { lines } = envData diff --git a/src/app/api/status/route.ts b/src/app/api/status/route.ts index 3c286d5..d26886b 100644 --- a/src/app/api/status/route.ts +++ b/src/app/api/status/route.ts @@ -475,7 +475,10 @@ async function performHealthCheck() { async function getCapabilities() { const gateway = await isPortOpen(config.gatewayHost, config.gatewayPort) - const openclawHome = !!(config.openclawHome && existsSync(config.openclawHome)) + const openclawHome = Boolean( + (config.openclawStateDir && existsSync(config.openclawStateDir)) || + (config.openclawConfigPath && existsSync(config.openclawConfigPath)) + ) const claudeProjectsPath = path.join(config.claudeHome, 'projects') const claudeHome = existsSync(claudeProjectsPath) diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 176acd2..ca5f0bf 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -1,6 +1,9 @@ 'use client' import { Component, type ErrorInfo, type ReactNode } from 'react' +import { createClientLogger } from '@/lib/client-logger' + +const log = createClientLogger('ErrorBoundary') interface Props { children: ReactNode @@ -23,7 +26,7 @@ export class ErrorBoundary extends Component { } componentDidCatch(error: Error, errorInfo: ErrorInfo) { - console.error('Panel error:', error, errorInfo) + log.error('Panel error:', error, errorInfo) } render() { diff --git a/src/components/chat/chat-panel.tsx b/src/components/chat/chat-panel.tsx index 0b23942..3584c3b 100644 --- a/src/components/chat/chat-panel.tsx +++ b/src/components/chat/chat-panel.tsx @@ -3,10 +3,13 @@ import { useEffect, useCallback, useState, useRef } from 'react' import { useMissionControl } from '@/store' import { useSmartPoll } from '@/lib/use-smart-poll' +import { createClientLogger } from '@/lib/client-logger' import { ConversationList } from './conversation-list' import { MessageList } from './message-list' import { ChatInput } from './chat-input' +const log = createClientLogger('ChatPanel') + export function ChatPanel() { const { chatPanelOpen, @@ -52,7 +55,7 @@ export function ChatPanel() { const data = await res.json() if (data.agents) setAgents(data.agents) } catch (err) { - console.error('Failed to load agents:', err) + log.error('Failed to load agents:', err) } } if (chatPanelOpen) loadAgents() @@ -67,7 +70,7 @@ export function ChatPanel() { const data = await res.json() if (data.messages) setChatMessages(data.messages) } catch (err) { - console.error('Failed to load messages:', err) + log.error('Failed to load messages:', err) } }, [activeConversation, setChatMessages]) @@ -145,7 +148,7 @@ export function ChatPanel() { updatePendingMessage(tempId, { pendingStatus: 'failed' }) } } catch (err) { - console.error('Failed to send message:', err) + log.error('Failed to send message:', err) updatePendingMessage(tempId, { pendingStatus: 'failed' }) } } diff --git a/src/components/chat/conversation-list.tsx b/src/components/chat/conversation-list.tsx index ee55a83..5a731d0 100644 --- a/src/components/chat/conversation-list.tsx +++ b/src/components/chat/conversation-list.tsx @@ -3,6 +3,9 @@ import { useState, useCallback } from 'react' import { useMissionControl, Conversation, Agent } from '@/store' import { useSmartPoll } from '@/lib/use-smart-poll' +import { createClientLogger } from '@/lib/client-logger' + +const log = createClientLogger('ConversationList') function timeAgo(timestamp: number): string { const diff = Math.floor(Date.now() / 1000) - timestamp @@ -64,7 +67,7 @@ export function ConversationList({ onNewConversation }: ConversationListProps) { ) } } catch (err) { - console.error('Failed to load conversations:', err) + log.error('Failed to load conversations:', err) } }, [setConversations]) diff --git a/src/components/dashboard/sidebar.tsx b/src/components/dashboard/sidebar.tsx index 756caa2..1b07d72 100644 --- a/src/components/dashboard/sidebar.tsx +++ b/src/components/dashboard/sidebar.tsx @@ -3,6 +3,9 @@ import { useEffect, useState } from 'react' import { useMissionControl } from '@/store' import { useNavigateToPanel } from '@/lib/navigation' +import { createClientLogger } from '@/lib/client-logger' + +const log = createClientLogger('Sidebar') interface MenuItem { id: string @@ -36,7 +39,7 @@ export function Sidebar() { fetch('/api/status?action=overview') .then(res => res.json()) .then(data => setSystemStats(data)) - .catch(err => console.error('Failed to fetch system status:', err)) + .catch(err => log.error('Failed to fetch system status:', err)) }, []) const activeSessions = sessions.filter(s => s.active).length diff --git a/src/components/panels/agent-cost-panel.tsx b/src/components/panels/agent-cost-panel.tsx index fd34b75..9a9e559 100644 --- a/src/components/panels/agent-cost-panel.tsx +++ b/src/components/panels/agent-cost-panel.tsx @@ -1,11 +1,14 @@ 'use client' import { useState, useEffect, useCallback } from 'react' +import { createClientLogger } from '@/lib/client-logger' import { PieChart, Pie, Cell, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, } from 'recharts' +const log = createClientLogger('AgentCostPanel') + interface AgentCostData { stats: { totalTokens: number; totalCost: number; requestCount: number; avgTokensPerRequest: number; avgCostPerRequest: number } models: Record @@ -34,7 +37,7 @@ export function AgentCostPanel() { const json = await res.json() setData(json) } catch (err) { - console.error('Failed to load agent costs:', err) + log.error('Failed to load agent costs:', err) } finally { setIsLoading(false) } diff --git a/src/components/panels/agent-detail-tabs.tsx b/src/components/panels/agent-detail-tabs.tsx index b77354a..2012eae 100644 --- a/src/components/panels/agent-detail-tabs.tsx +++ b/src/components/panels/agent-detail-tabs.tsx @@ -1,8 +1,11 @@ 'use client' import { useState, useEffect } from 'react' +import { createClientLogger } from '@/lib/client-logger' import Link from 'next/link' +const log = createClientLogger('AgentDetailTabs') + interface Agent { id: number name: string @@ -374,7 +377,7 @@ export function SoulTab({ setSelectedTemplate(templateName) } } catch (error) { - console.error('Failed to load template:', error) + log.error('Failed to load template:', error) } } @@ -626,7 +629,7 @@ export function TasksTab({ agent }: { agent: Agent }) { setTasks(data.tasks || []) } } catch (error) { - console.error('Failed to fetch tasks:', error) + log.error('Failed to fetch tasks:', error) } finally { setLoading(false) } @@ -725,7 +728,7 @@ export function ActivityTab({ agent }: { agent: Agent }) { setActivities(data.activities || []) } } catch (error) { - console.error('Failed to fetch activities:', error) + log.error('Failed to fetch activities:', error) } finally { setLoading(false) } diff --git a/src/components/panels/agent-spawn-panel.tsx b/src/components/panels/agent-spawn-panel.tsx index dc8822a..24409ca 100644 --- a/src/components/panels/agent-spawn-panel.tsx +++ b/src/components/panels/agent-spawn-panel.tsx @@ -2,6 +2,9 @@ import { useState, useEffect } from 'react' import { useMissionControl } from '@/store' +import { createClientLogger } from '@/lib/client-logger' + +const log = createClientLogger('AgentSpawnPanel') interface SpawnFormData { task: string @@ -33,7 +36,7 @@ export function AgentSpawnPanel() { fetch('/api/spawn') .then(res => res.json()) .then(data => setSpawnHistory(data.history || [])) - .catch(err => console.error('Failed to load spawn history:', err)) + .catch(err => log.error('Failed to load spawn history:', err)) }, []) const handleSpawn = async () => { @@ -95,7 +98,7 @@ export function AgentSpawnPanel() { }) } } catch (error) { - console.error('Spawn error:', error) + log.error('Spawn error:', error) updateSpawnRequest(spawnId, { status: 'failed', error: error instanceof Error ? error.message : 'Network error' diff --git a/src/components/panels/agent-squad-panel-phase3.tsx b/src/components/panels/agent-squad-panel-phase3.tsx index f3f03a9..8905d31 100644 --- a/src/components/panels/agent-squad-panel-phase3.tsx +++ b/src/components/panels/agent-squad-panel-phase3.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react' import { useSmartPoll } from '@/lib/use-smart-poll' +import { createClientLogger } from '@/lib/client-logger' import { AgentAvatar } from '@/components/ui/agent-avatar' import { OverviewTab, @@ -13,6 +14,8 @@ import { CreateAgentModal } from './agent-detail-tabs' +const log = createClientLogger('AgentSquadPhase3') + interface Agent { id: number name: string @@ -148,7 +151,7 @@ export function AgentSquadPanelPhase3() { : agent )) } catch (error) { - console.error('Failed to update agent status:', error) + log.error('Failed to update agent status:', error) setError('Failed to update agent status') } } @@ -171,7 +174,7 @@ export function AgentSquadPanelPhase3() { await updateAgentStatus(agentName, 'idle', 'Manually woken via session') } catch (error) { - console.error('Failed to wake agent:', error) + log.error('Failed to wake agent:', error) setError('Failed to wake agent') } } @@ -503,7 +506,7 @@ function AgentDetailModalPhase3({ setSoulTemplates(data.templates || []) } } catch (error) { - console.error('Failed to load SOUL templates:', error) + log.error('Failed to load SOUL templates:', error) } } @@ -522,7 +525,7 @@ function AgentDetailModalPhase3({ setHeartbeatData(data) } } catch (error) { - console.error('Failed to perform heartbeat:', error) + log.error('Failed to perform heartbeat:', error) } finally { setLoadingHeartbeat(false) } @@ -544,7 +547,7 @@ function AgentDetailModalPhase3({ setEditing(false) onUpdate() } catch (error) { - console.error('Failed to update agent:', error) + log.error('Failed to update agent:', error) } } @@ -564,7 +567,7 @@ function AgentDetailModalPhase3({ setFormData(prev => ({ ...prev, soul_content: content })) onUpdate() } catch (error) { - console.error('Failed to update SOUL:', error) + log.error('Failed to update SOUL:', error) } } @@ -585,7 +588,7 @@ function AgentDetailModalPhase3({ setFormData(prev => ({ ...prev, working_memory: data.working_memory })) onUpdate() } catch (error) { - console.error('Failed to update memory:', error) + log.error('Failed to update memory:', error) } } @@ -746,7 +749,7 @@ function QuickSpawnModal({ alert(result.error || 'Failed to spawn agent') } } catch (error) { - console.error('Spawn failed:', error) + log.error('Spawn failed:', error) alert('Network error occurred') } finally { setIsSpawning(false) diff --git a/src/components/panels/agent-squad-panel.tsx b/src/components/panels/agent-squad-panel.tsx index f71ba29..a01439a 100644 --- a/src/components/panels/agent-squad-panel.tsx +++ b/src/components/panels/agent-squad-panel.tsx @@ -1,6 +1,9 @@ 'use client' import { useState, useEffect, useCallback } from 'react' +import { createClientLogger } from '@/lib/client-logger' + +const log = createClientLogger('AgentSquadPanel') interface Agent { id: number @@ -103,7 +106,7 @@ export function AgentSquadPanel() { : agent )) } catch (error) { - console.error('Failed to update agent status:', error) + log.error('Failed to update agent status:', error) setError('Failed to update agent status') } } @@ -355,7 +358,7 @@ function AgentDetailModal({ setEditing(false) onUpdate() } catch (error) { - console.error('Failed to update agent:', error) + log.error('Failed to update agent:', error) } } @@ -540,7 +543,7 @@ function CreateAgentModal({ onCreated() onClose() } catch (error) { - console.error('Error creating agent:', error) + log.error('Error creating agent:', error) } } diff --git a/src/components/panels/cron-management-panel.tsx b/src/components/panels/cron-management-panel.tsx index c5de4e6..7090834 100644 --- a/src/components/panels/cron-management-panel.tsx +++ b/src/components/panels/cron-management-panel.tsx @@ -2,6 +2,8 @@ import { useState, useEffect, useCallback, useMemo } from 'react' import { useMissionControl, CronJob } from '@/store' +import { createClientLogger } from '@/lib/client-logger' +const log = createClientLogger('CronManagement') import { buildDayKey, getCronOccurrences } from '@/lib/cron-occurrences' interface NewJobForm { @@ -92,7 +94,7 @@ export function CronManagementPanel() { const data = await response.json() setCronJobs(data.jobs || []) } catch (error) { - console.error('Failed to load cron jobs:', error) + log.error('Failed to load cron jobs:', error) } finally { setIsLoading(false) } @@ -126,7 +128,7 @@ export function CronManagementPanel() { const data = await response.json() setJobLogs(data.logs || []) } catch (error) { - console.error('Failed to load job logs:', error) + log.error('Failed to load job logs:', error) setJobLogs([]) } } @@ -150,7 +152,7 @@ export function CronManagementPanel() { alert(`Failed to toggle job: ${error.error}`) } } catch (error) { - console.error('Failed to toggle job:', error) + log.error('Failed to toggle job:', error) alert('Network error occurred') } } @@ -175,7 +177,7 @@ export function CronManagementPanel() { alert(`Job failed:\n${result.error}\n${result.stderr}`) } } catch (error) { - console.error('Failed to trigger job:', error) + log.error('Failed to trigger job:', error) alert('Network error occurred') } } @@ -214,7 +216,7 @@ export function CronManagementPanel() { alert(`Failed to add job: ${error.error}`) } } catch (error) { - console.error('Failed to add job:', error) + log.error('Failed to add job:', error) alert('Network error occurred') } } @@ -244,7 +246,7 @@ export function CronManagementPanel() { alert(`Failed to remove job: ${error.error}`) } } catch (error) { - console.error('Failed to remove job:', error) + log.error('Failed to remove job:', error) alert('Network error occurred') } } diff --git a/src/components/panels/gateway-config-panel.tsx b/src/components/panels/gateway-config-panel.tsx index 3ddff4f..9daf69b 100644 --- a/src/components/panels/gateway-config-panel.tsx +++ b/src/components/panels/gateway-config-panel.tsx @@ -113,7 +113,7 @@ export function GatewayConfigPanel() {
{error}

- Ensure OPENCLAW_HOME is set and openclaw.json exists at the expected path. + Ensure `OPENCLAW_CONFIG_PATH` (or `OPENCLAW_STATE_DIR`) is set and the config file exists.

) diff --git a/src/components/panels/log-viewer-panel.tsx b/src/components/panels/log-viewer-panel.tsx index 3f76c11..300b2a9 100644 --- a/src/components/panels/log-viewer-panel.tsx +++ b/src/components/panels/log-viewer-panel.tsx @@ -3,6 +3,9 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { useMissionControl } from '@/store' import { useSmartPoll } from '@/lib/use-smart-poll' +import { createClientLogger } from '@/lib/client-logger' + +const log = createClientLogger('LogViewer') interface LogFilters { level?: string @@ -36,7 +39,7 @@ export function LogViewerPanel() { }, [logFilters]) const loadLogs = useCallback(async (tail = false) => { - console.log(`LogViewer: Loading logs (tail=${tail})`) + log.debug(`Loading logs (tail=${tail})`) setIsLoading(!tail) // Only show loading for initial load, not for tailing try { @@ -53,37 +56,37 @@ export function LogViewerPanel() { ...(tail && currentLogs.length > 0 && { since: currentLogs[0]?.timestamp.toString() }) }) - console.log(`LogViewer: Fetching /api/logs?${params}`) + log.debug(`Fetching /api/logs?${params}`) const response = await fetch(`/api/logs?${params}`) const data = await response.json() - console.log(`LogViewer: Received ${data.logs?.length || 0} logs from API`) + log.debug(`Received ${data.logs?.length || 0} logs from API`) if (data.logs && data.logs.length > 0) { if (tail) { // Add new logs for tail mode - prepend to existing logs let newLogsAdded = 0 const existingIds = new Set((currentLogs || []).map((l: any) => l?.id).filter(Boolean)) - data.logs.reverse().forEach((log: any) => { - if (existingIds.has(log?.id)) return - addLog(log) + data.logs.reverse().forEach((entry: any) => { + if (existingIds.has(entry?.id)) return + addLog(entry) newLogsAdded++ }) - console.log(`LogViewer: Added ${newLogsAdded} new logs (tail mode)`) + log.debug(`Added ${newLogsAdded} new logs (tail mode)`) } else { // Replace logs for initial load or refresh - console.log(`LogViewer: Clearing existing logs and loading ${data.logs.length} logs`) + log.debug(`Clearing existing logs and loading ${data.logs.length} logs`) clearLogs() // Clear existing logs - data.logs.reverse().forEach((log: any) => { - addLog(log) + data.logs.reverse().forEach((entry: any) => { + addLog(entry) }) - console.log(`LogViewer: Successfully added ${data.logs.length} logs to store`) + log.debug(`Successfully added ${data.logs.length} logs to store`) } } else { - console.log('LogViewer: No logs received from API') + log.debug('No logs received from API') } } catch (error) { - console.error('LogViewer: Failed to load logs:', error) + log.error('Failed to load logs:', error) } finally { setIsLoading(false) } @@ -95,13 +98,13 @@ export function LogViewerPanel() { const data = await response.json() setAvailableSources(data.sources || []) } catch (error) { - console.error('Failed to load log sources:', error) + log.error('Failed to load log sources:', error) } }, []) // Load initial logs and sources useEffect(() => { - console.log('LogViewer: Initial load started') + log.debug('Initial load started') loadLogs() loadSources() }, [loadLogs, loadSources]) @@ -154,16 +157,16 @@ export function LogViewerPanel() { } } - const filteredLogs = logs.filter(log => { - if (logFilters.level && log.level !== logFilters.level) return false - if (logFilters.source && log.source !== logFilters.source) return false - if (logFilters.search && !log.message.toLowerCase().includes(logFilters.search.toLowerCase())) return false - if (logFilters.session && (!log.session || !log.session.includes(logFilters.session))) return false + const filteredLogs = logs.filter(entry => { + if (logFilters.level && entry.level !== logFilters.level) return false + if (logFilters.source && entry.source !== logFilters.source) return false + if (logFilters.search && !entry.message.toLowerCase().includes(logFilters.search.toLowerCase())) return false + if (logFilters.session && (!entry.session || !entry.session.includes(logFilters.session))) return false return true }) // Debug logging - console.log(`LogViewer: Store has ${logs.length} logs, filtered to ${filteredLogs.length}`) + log.debug(`Store has ${logs.length} logs, filtered to ${filteredLogs.length}`) return (
diff --git a/src/components/panels/memory-browser-panel.tsx b/src/components/panels/memory-browser-panel.tsx index 453526e..61c9229 100644 --- a/src/components/panels/memory-browser-panel.tsx +++ b/src/components/panels/memory-browser-panel.tsx @@ -2,6 +2,9 @@ import React, { useState, useEffect, useCallback } from 'react' import { useMissionControl } from '@/store' +import { createClientLogger } from '@/lib/client-logger' + +const log = createClientLogger('MemoryBrowser') interface MemoryFile { path: string @@ -46,7 +49,7 @@ export function MemoryBrowserPanel() { // Auto-expand some common directories setExpandedFolders(new Set(['daily', 'knowledge'])) } catch (error) { - console.error('Failed to load file tree:', error) + log.error('Failed to load file tree:', error) } finally { setIsLoading(false) } @@ -83,7 +86,7 @@ export function MemoryBrowserPanel() { alert(data.error || 'Failed to load file content') } } catch (error) { - console.error('Failed to load file content:', error) + log.error('Failed to load file content:', error) alert('Network error occurred') } finally { setIsLoading(false) @@ -99,7 +102,7 @@ export function MemoryBrowserPanel() { const data = await response.json() setSearchResults(data.results || []) } catch (error) { - console.error('Search failed:', error) + log.error('Search failed:', error) setSearchResults([]) } finally { setIsSearching(false) @@ -165,7 +168,7 @@ export function MemoryBrowserPanel() { alert(data.error || 'Failed to save file') } } catch (error) { - console.error('Failed to save file:', error) + log.error('Failed to save file:', error) alert('Network error occurred') } finally { setIsSaving(false) @@ -192,7 +195,7 @@ export function MemoryBrowserPanel() { alert(data.error || 'Failed to create file') } } catch (error) { - console.error('Failed to create file:', error) + log.error('Failed to create file:', error) alert('Network error occurred') } } @@ -220,7 +223,7 @@ export function MemoryBrowserPanel() { alert(data.error || 'Failed to delete file') } } catch (error) { - console.error('Failed to delete file:', error) + log.error('Failed to delete file:', error) alert('Network error occurred') } } @@ -271,6 +274,30 @@ export function MemoryBrowserPanel() { )) } + const renderInlineFormatting = (text: string): React.ReactNode[] => { + const parts: React.ReactNode[] = [] + const regex = /(\*\*.*?\*\*|\*.*?\*)/g + let lastIndex = 0 + let match: RegExpExecArray | null + let key = 0 + while ((match = regex.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)) + } + const m = match[0] + if (m.startsWith('**') && m.endsWith('**')) { + parts.push({m.slice(2, -2)}) + } else if (m.startsWith('*') && m.endsWith('*')) { + parts.push({m.slice(1, -1)}) + } + lastIndex = regex.lastIndex + } + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)) + } + return parts + } + const renderMarkdown = (content: string) => { // Improved markdown rendering with proper line handling const lines = content.split('\n') @@ -323,20 +350,10 @@ export function MemoryBrowserPanel() { elements.push(
) } else if (trimmedLine.length > 0) { if (inList) inList = false - // Handle inline formatting — escape HTML entities first to prevent XSS - let content = trimmedLine - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') - // Simple bold formatting - content = content.replace(/\*\*(.*?)\*\*/g, '$1') - // Simple italic formatting - content = content.replace(/\*(.*?)\*/g, '$1') - elements.push( -

+

+ {renderInlineFormatting(trimmedLine)} +

) } } diff --git a/src/components/panels/session-details-panel.tsx b/src/components/panels/session-details-panel.tsx index be9ecac..c41c73a 100644 --- a/src/components/panels/session-details-panel.tsx +++ b/src/components/panels/session-details-panel.tsx @@ -3,6 +3,9 @@ import { useState, useCallback } from 'react' import { useMissionControl } from '@/store' import { useSmartPoll } from '@/lib/use-smart-poll' +import { createClientLogger } from '@/lib/client-logger' + +const log = createClientLogger('SessionDetails') export function SessionDetailsPanel() { const { @@ -20,7 +23,7 @@ export function SessionDetailsPanel() { const data = await response.json() setSessions(data.sessions || data) } catch (error) { - console.error('Failed to load sessions:', error) + log.error('Failed to load sessions:', error) } }, [setSessions]) diff --git a/src/components/panels/standup-panel.tsx b/src/components/panels/standup-panel.tsx index 0e707ed..2d2e044 100644 --- a/src/components/panels/standup-panel.tsx +++ b/src/components/panels/standup-panel.tsx @@ -1,6 +1,9 @@ 'use client' import { useState, useEffect } from 'react' +import { createClientLogger } from '@/lib/client-logger' + +const log = createClientLogger('StandupPanel') interface StandupReport { date: string @@ -133,7 +136,7 @@ export function StandupPanel() { const data = await response.json() setStandupHistory(data.history || []) } catch (err) { - console.error('Failed to fetch standup history:', err) + log.error('Failed to fetch standup history:', err) } } diff --git a/src/components/panels/task-board-panel.tsx b/src/components/panels/task-board-panel.tsx index 760c9db..6401e2c 100644 --- a/src/components/panels/task-board-panel.tsx +++ b/src/components/panels/task-board-panel.tsx @@ -4,10 +4,16 @@ import { useState, useEffect, useCallback, useRef } from 'react' import { usePathname, useRouter, useSearchParams } from 'next/navigation' import { useMissionControl } from '@/store' import { useSmartPoll } from '@/lib/use-smart-poll' + +import { createClientLogger } from '@/lib/client-logger' + import { useFocusTrap } from '@/lib/use-focus-trap' + import { AgentAvatar } from '@/components/ui/agent-avatar' import { MarkdownRenderer } from '@/components/markdown-renderer' +const log = createClientLogger('TaskBoard') + interface Task { id: number title: string @@ -1024,7 +1030,7 @@ function CreateTaskModal({ onCreated() onClose() } catch (error) { - console.error('Error creating task:', error) + log.error('Error creating task:', error) } } @@ -1193,7 +1199,7 @@ function EditTaskModal({ onUpdated() } catch (error) { - console.error('Error updating task:', error) + log.error('Error updating task:', error) } } diff --git a/src/components/panels/token-dashboard-panel.tsx b/src/components/panels/token-dashboard-panel.tsx index 4f46c51..9e850f4 100644 --- a/src/components/panels/token-dashboard-panel.tsx +++ b/src/components/panels/token-dashboard-panel.tsx @@ -2,8 +2,11 @@ import { useState, useEffect, useCallback } from 'react' import { useMissionControl } from '@/store' +import { createClientLogger } from '@/lib/client-logger' import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, BarChart, Bar, PieChart, Pie, Cell } from 'recharts' +const log = createClientLogger('TokenDashboard') + interface UsageStats { summary: { totalTokens: number @@ -39,7 +42,7 @@ export function TokenDashboardPanel() { const data = await response.json() setUsageStats(data) } catch (error) { - console.error('Failed to load usage stats:', error) + log.error('Failed to load usage stats:', error) } finally { setIsLoading(false) } @@ -51,7 +54,7 @@ export function TokenDashboardPanel() { const data = await response.json() setTrendData(data) } catch (error) { - console.error('Failed to load trend data:', error) + log.error('Failed to load trend data:', error) } }, [selectedTimeframe]) @@ -80,7 +83,7 @@ export function TokenDashboardPanel() { window.URL.revokeObjectURL(url) document.body.removeChild(a) } catch (error) { - console.error('Export failed:', error) + log.error('Export failed:', error) alert('Export failed: ' + error) } finally { setIsExporting(false) diff --git a/src/lib/agent-sync.ts b/src/lib/agent-sync.ts index 57a91ae..e0d6416 100644 --- a/src/lib/agent-sync.ts +++ b/src/lib/agent-sync.ts @@ -8,7 +8,7 @@ import { config } from './config' import { getDatabase, db_helpers, logAuditEvent } from './db' import { eventBus } from './event-bus' -import { join } from 'path' +import { join, isAbsolute, resolve } from 'path' import { existsSync, readFileSync } from 'fs' import { resolveWithin } from './paths' import { logger } from './logger' @@ -127,15 +127,22 @@ function parseToolsFromFile(content: string): { allow?: string[]; raw?: string } } function getConfigPath(): string | null { - if (!config.openclawHome) return null - return join(config.openclawHome, 'openclaw.json') + return config.openclawConfigPath || null +} + +function resolveAgentWorkspacePath(workspace: string): string { + if (isAbsolute(workspace)) return resolve(workspace) + if (!config.openclawStateDir) { + throw new Error('OPENCLAW_STATE_DIR not configured') + } + return resolveWithin(config.openclawStateDir, workspace) } /** Safely read a file from an agent's workspace directory */ function readWorkspaceFile(workspace: string | undefined, filename: string): string | null { - if (!workspace || !config.openclawHome) return null + if (!workspace) return null try { - const safeWorkspace = resolveWithin(config.openclawHome, workspace) + const safeWorkspace = resolveAgentWorkspacePath(workspace) const safePath = resolveWithin(safeWorkspace, filename) if (existsSync(safePath)) { return readFileSync(safePath, 'utf-8') @@ -173,7 +180,7 @@ export function enrichAgentConfigFromWorkspace(configData: any): any { /** Read and parse openclaw.json agents list */ async function readOpenClawAgents(): Promise { const configPath = getConfigPath() - if (!configPath) throw new Error('OPENCLAW_HOME not configured') + if (!configPath) throw new Error('OPENCLAW_CONFIG_PATH not configured') const { readFile } = require('fs/promises') const raw = await readFile(configPath, 'utf-8') @@ -334,7 +341,7 @@ export async function previewSyncDiff(): Promise { /** Write an agent config back to openclaw.json agents.list */ export async function writeAgentToConfig(agentConfig: any): Promise { const configPath = getConfigPath() - if (!configPath) throw new Error('OPENCLAW_HOME not configured') + if (!configPath) throw new Error('OPENCLAW_CONFIG_PATH not configured') const { readFile, writeFile } = require('fs/promises') const raw = await readFile(configPath, 'utf-8') diff --git a/src/lib/client-logger.ts b/src/lib/client-logger.ts new file mode 100644 index 0000000..b18c699 --- /dev/null +++ b/src/lib/client-logger.ts @@ -0,0 +1,68 @@ +/** + * Lightweight structured logger for client-side ('use client') components. + * + * Mirrors pino's API surface (info/warn/error/debug) so call sites are + * consistent with the server-side logger in src/lib/logger.ts. + * In production builds, debug and info messages are suppressed. + */ + +type LogLevel = 'debug' | 'info' | 'warn' | 'error' + +const LOG_LEVELS: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +} + +const minLevel: number = + process.env.NODE_ENV === 'production' ? LOG_LEVELS.warn : LOG_LEVELS.debug + +function shouldLog(level: LogLevel): boolean { + return LOG_LEVELS[level] >= minLevel +} + +function formatArgs( + level: LogLevel, + module: string, + msgOrObj: unknown, + ...rest: unknown[] +): unknown[] { + const prefix = `[${level.toUpperCase()}] ${module}:` + if (typeof msgOrObj === 'string') { + return [prefix, msgOrObj, ...rest] + } + return [prefix, msgOrObj, ...rest] +} + +export interface ClientLogger { + debug(msg: string, ...args: unknown[]): void + debug(obj: Record, msg?: string): void + info(msg: string, ...args: unknown[]): void + info(obj: Record, msg?: string): void + warn(msg: string, ...args: unknown[]): void + warn(obj: Record, msg?: string): void + error(msg: string, ...args: unknown[]): void + error(obj: Record, msg?: string): void +} + +export function createClientLogger(module: string): ClientLogger { + return { + debug(msgOrObj: unknown, ...rest: unknown[]) { + if (!shouldLog('debug')) return + console.debug(...formatArgs('debug', module, msgOrObj, ...rest)) + }, + info(msgOrObj: unknown, ...rest: unknown[]) { + if (!shouldLog('info')) return + console.info(...formatArgs('info', module, msgOrObj, ...rest)) + }, + warn(msgOrObj: unknown, ...rest: unknown[]) { + if (!shouldLog('warn')) return + console.warn(...formatArgs('warn', module, msgOrObj, ...rest)) + }, + error(msgOrObj: unknown, ...rest: unknown[]) { + if (!shouldLog('error')) return + console.error(...formatArgs('error', module, msgOrObj, ...rest)) + }, + } +} diff --git a/src/lib/command.ts b/src/lib/command.ts index b1b4705..70313e9 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -74,13 +74,13 @@ export function runCommand( export function runOpenClaw(args: string[], options: CommandOptions = {}) { return runCommand(config.openclawBin, args, { ...options, - cwd: options.cwd || config.openclawHome || process.cwd() + cwd: options.cwd || config.openclawStateDir || process.cwd() }) } export function runClawdbot(args: string[], options: CommandOptions = {}) { return runCommand(config.clawdbotBin, args, { ...options, - cwd: options.cwd || config.openclawHome || process.cwd() + cwd: options.cwd || config.openclawStateDir || process.cwd() }) } diff --git a/src/lib/config.ts b/src/lib/config.ts index b6a4ca2..54214c1 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -3,11 +3,24 @@ import os from 'node:os' import path from 'node:path' const defaultDataDir = path.join(process.cwd(), '.data') -const openclawHome = +const defaultOpenClawStateDir = path.join(os.homedir(), '.openclaw') +const explicitOpenClawConfigPath = + process.env.OPENCLAW_CONFIG_PATH || + process.env.MISSION_CONTROL_OPENCLAW_CONFIG_PATH || + '' +const legacyOpenClawHome = process.env.OPENCLAW_HOME || process.env.CLAWDBOT_HOME || process.env.MISSION_CONTROL_OPENCLAW_HOME || '' +const openclawStateDir = + process.env.OPENCLAW_STATE_DIR || + process.env.CLAWDBOT_STATE_DIR || + legacyOpenClawHome || + (explicitOpenClawConfigPath ? path.dirname(explicitOpenClawConfigPath) : defaultOpenClawStateDir) +const openclawConfigPath = + explicitOpenClawConfigPath || + path.join(openclawStateDir, 'openclaw.json') export const config = { claudeHome: @@ -20,22 +33,25 @@ export const config = { tokensPath: process.env.MISSION_CONTROL_TOKENS_PATH || path.join(defaultDataDir, 'mission-control-tokens.json'), - openclawHome, + // Keep openclawHome as a legacy alias for existing code paths. + openclawHome: openclawStateDir, + openclawStateDir, + openclawConfigPath, openclawBin: process.env.OPENCLAW_BIN || 'openclaw', clawdbotBin: process.env.CLAWDBOT_BIN || 'clawdbot', gatewayHost: process.env.OPENCLAW_GATEWAY_HOST || '127.0.0.1', gatewayPort: Number(process.env.OPENCLAW_GATEWAY_PORT || '18789'), logsDir: process.env.OPENCLAW_LOG_DIR || - (openclawHome ? path.join(openclawHome, 'logs') : ''), + (openclawStateDir ? path.join(openclawStateDir, 'logs') : ''), tempLogsDir: process.env.CLAWDBOT_TMP_LOG_DIR || '', memoryDir: process.env.OPENCLAW_MEMORY_DIR || - (openclawHome ? path.join(openclawHome, 'memory') : '') || + (openclawStateDir ? path.join(openclawStateDir, 'memory') : '') || path.join(defaultDataDir, 'memory'), soulTemplatesDir: process.env.OPENCLAW_SOUL_TEMPLATES_DIR || - (openclawHome ? path.join(openclawHome, 'templates', 'souls') : ''), + (openclawStateDir ? path.join(openclawStateDir, 'templates', 'souls') : ''), homeDir: os.homedir(), // Data retention (days). 0 = keep forever. retention: { diff --git a/src/lib/device-identity.ts b/src/lib/device-identity.ts index e1d28e6..9a2cef0 100644 --- a/src/lib/device-identity.ts +++ b/src/lib/device-identity.ts @@ -1,5 +1,9 @@ 'use client' +import { createClientLogger } from '@/lib/client-logger' + +const log = createClientLogger('DeviceIdentity') + /** * Ed25519 device identity for OpenClaw gateway protocol v3 challenge-response. * @@ -101,7 +105,7 @@ export async function getOrCreateDeviceIdentity(): Promise { } } catch { // Stored key corrupted — regenerate - console.warn('Device identity keys corrupted, regenerating...') + log.warn('Device identity keys corrupted, regenerating...') } } diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts index 8983e6e..17421fd 100644 --- a/src/lib/sessions.ts +++ b/src/lib/sessions.ts @@ -23,16 +23,16 @@ export interface GatewaySession { * Read all sessions from OpenClaw agent session stores on disk. * * OpenClaw stores sessions per-agent at: - * {OPENCLAW_HOME}/agents/{agentName}/sessions/sessions.json + * {OPENCLAW_STATE_DIR}/agents/{agentName}/sessions/sessions.json * * Each file is a JSON object keyed by session key (e.g. "agent::main") * with session metadata as values. */ export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewaySession[] { - const openclawHome = config.openclawHome - if (!openclawHome) return [] + const openclawStateDir = config.openclawStateDir + if (!openclawStateDir) return [] - const agentsDir = path.join(openclawHome, 'agents') + const agentsDir = path.join(openclawStateDir, 'agents') if (!fs.existsSync(agentsDir)) return [] const sessions: GatewaySession[] = [] diff --git a/src/lib/use-server-events.ts b/src/lib/use-server-events.ts index a0bbf7a..347c12f 100644 --- a/src/lib/use-server-events.ts +++ b/src/lib/use-server-events.ts @@ -2,6 +2,9 @@ import { useEffect, useRef } from 'react' import { useMissionControl } from '@/store' +import { createClientLogger } from '@/lib/client-logger' + +const log = createClientLogger('SSE') interface ServerEvent { type: string @@ -73,7 +76,7 @@ export function useServerEvents() { const attempts = sseReconnectAttemptsRef.current if (attempts >= SSE_MAX_RECONNECT_ATTEMPTS) { - console.error(`SSE: max reconnect attempts (${SSE_MAX_RECONNECT_ATTEMPTS}) reached`) + log.error(`Max reconnect attempts (${SSE_MAX_RECONNECT_ATTEMPTS}) reached`) return } @@ -82,7 +85,7 @@ export function useServerEvents() { const delay = Math.round(base + Math.random() * base * 0.5) sseReconnectAttemptsRef.current = attempts + 1 - console.warn(`SSE: reconnecting in ${delay}ms (attempt ${attempts + 1}/${SSE_MAX_RECONNECT_ATTEMPTS})`) + log.warn(`Reconnecting in ${delay}ms (attempt ${attempts + 1}/${SSE_MAX_RECONNECT_ATTEMPTS})`) reconnectTimeoutRef.current = setTimeout(() => { if (mounted) connect() }, delay) diff --git a/src/lib/websocket.ts b/src/lib/websocket.ts index 663de63..a1ab370 100644 --- a/src/lib/websocket.ts +++ b/src/lib/websocket.ts @@ -10,6 +10,9 @@ import { cacheDeviceToken, } from '@/lib/device-identity' import { APP_VERSION } from '@/lib/version' +import { createClientLogger } from '@/lib/client-logger' + +const log = createClientLogger('WebSocket') // Gateway protocol version (v3 required by OpenClaw 2026.x) const PROTOCOL_VERSION = 3 @@ -116,7 +119,7 @@ export function useWebSocket() { // Check missed pongs if (missedPongsRef.current >= MAX_MISSED_PONGS) { - console.warn(`Missed ${MAX_MISSED_PONGS} pongs, triggering reconnect`) + log.warn(`Missed ${MAX_MISSED_PONGS} pongs, triggering reconnect`) addLog({ id: `heartbeat-${Date.now()}`, timestamp: Date.now(), @@ -213,7 +216,7 @@ export function useWebSocket() { nonce, } } catch (err) { - console.warn('Device identity unavailable, proceeding without:', err) + log.warn('Device identity unavailable, proceeding without:', err) } } @@ -239,7 +242,7 @@ export function useWebSocket() { deviceToken: cachedToken || undefined, } } - console.log('Sending connect handshake:', connectRequest) + log.info('Sending connect handshake') ws.send(JSON.stringify(connectRequest)) }, []) @@ -249,7 +252,7 @@ export function useWebSocket() { // Debug logging for development if (process.env.NODE_ENV === 'development') { - console.log('WebSocket message received:', message.type, message) + log.debug(`Message received: ${message.type}`) } switch (message.type) { @@ -319,24 +322,24 @@ export function useWebSocket() { break default: - console.log('Unknown gateway message type:', message.type) + log.warn(`Unknown gateway message type: ${message.type}`) } }, [setLastMessage, setSessions, addLog, updateSpawnRequest, setCronJobs, addTokenUsage]) // Handle gateway protocol frames const handleGatewayFrame = useCallback((frame: GatewayFrame, ws: WebSocket) => { - console.log('Gateway frame:', frame) + log.debug(`Gateway frame: ${frame.type}`) // Handle connect challenge if (frame.type === 'event' && frame.event === 'connect.challenge') { - console.log('Received connect challenge, sending handshake...') + log.info('Received connect challenge, sending handshake') sendConnectHandshake(ws, frame.payload?.nonce) return } // Handle connect response (handshake success) if (frame.type === 'res' && frame.ok && !handshakeCompleteRef.current) { - console.log('Handshake complete!') + log.info('Handshake complete') handshakeCompleteRef.current = true reconnectAttemptsRef.current = 0 // Cache device token if returned by gateway @@ -361,7 +364,7 @@ export function useWebSocket() { // Handle connect error if (frame.type === 'res' && !frame.ok) { - console.error('Gateway error:', frame.error) + log.error(`Gateway error: ${frame.error?.message || JSON.stringify(frame.error)}`) const rawMessage = frame.error?.message || JSON.stringify(frame.error) const help = getGatewayErrorHelp(rawMessage) const nonRetryable = isNonRetryableGatewayError(rawMessage) @@ -510,14 +513,14 @@ export function useWebSocket() { wsRef.current = ws ws.onopen = () => { - console.log('WebSocket connected to', url.split('?')[0]) + log.info(`Connected to ${url.split('?')[0]}`) // Don't set isConnected yet - wait for handshake setConnection({ url: url.split('?')[0], reconnectAttempts: 0 }) // Wait for connect.challenge from server - console.log('Waiting for connect challenge...') + log.debug('Waiting for connect challenge') } ws.onmessage = (event) => { @@ -525,7 +528,7 @@ export function useWebSocket() { const frame = JSON.parse(event.data) as GatewayFrame handleGatewayFrame(frame, ws) } catch (error) { - console.error('Failed to parse WebSocket message:', error) + log.error('Failed to parse WebSocket message:', error) addLog({ id: `raw-${Date.now()}`, timestamp: Date.now(), @@ -537,7 +540,7 @@ export function useWebSocket() { } ws.onclose = (event) => { - console.log('Disconnected from Gateway:', event.code, event.reason) + log.info(`Disconnected from Gateway: ${event.code} ${event.reason}`) setConnection({ isConnected: false }) handshakeCompleteRef.current = false stopHeartbeat() @@ -555,7 +558,7 @@ export function useWebSocket() { if (attempts < maxReconnectAttempts) { const base = Math.min(Math.pow(2, attempts) * 1000, 30000) const timeout = Math.round(base + Math.random() * base * 0.5) - console.log(`Reconnecting in ${timeout}ms... (attempt ${attempts + 1}/${maxReconnectAttempts})`) + log.info(`Reconnecting in ${timeout}ms (attempt ${attempts + 1}/${maxReconnectAttempts})`) reconnectAttemptsRef.current = attempts + 1 setConnection({ reconnectAttempts: attempts + 1 }) @@ -563,7 +566,7 @@ export function useWebSocket() { connectRef.current(reconnectUrl.current, authTokenRef.current) }, timeout) } else { - console.error('Max reconnection attempts reached.') + log.error('Max reconnection attempts reached') addLog({ id: `error-${Date.now()}`, timestamp: Date.now(), @@ -575,7 +578,7 @@ export function useWebSocket() { } ws.onerror = (error) => { - console.error('WebSocket error:', error) + log.error('WebSocket error:', error) addLog({ id: `error-${Date.now()}`, timestamp: Date.now(), @@ -586,7 +589,7 @@ export function useWebSocket() { } } catch (error) { - console.error('Failed to connect to WebSocket:', error) + log.error('Failed to connect to WebSocket:', error) setConnection({ isConnected: false }) } }, [setConnection, handleGatewayFrame, addLog, stopHeartbeat]) diff --git a/tests/README.md b/tests/README.md index e75e461..8026c68 100644 --- a/tests/README.md +++ b/tests/README.md @@ -11,6 +11,9 @@ pnpm dev --hostname 127.0.0.1 --port 3005 # Run all tests pnpm test:e2e +# Run offline OpenClaw harness (no OpenClaw install required) +pnpm test:e2e:openclaw + # Run a specific spec pnpm exec playwright test tests/tasks-crud.spec.ts ``` @@ -21,6 +24,17 @@ Tests require `.env.local` with: - `API_KEY=test-api-key-e2e-12345` - `MC_DISABLE_RATE_LIMIT=1` (bypasses mutation/read rate limits, keeps login rate limit active) +## OpenClaw Offline Harness + +The harness runs Mission Control against fixture data and mock binaries/gateway: +- fixtures: `tests/fixtures/openclaw/` +- mock CLI: `scripts/e2e-openclaw/bin/{openclaw,clawdbot}` +- mock gateway: `scripts/e2e-openclaw/mock-gateway.mjs` + +Profiles: +- `pnpm test:e2e:openclaw:local` - local mode (gateway not running) +- `pnpm test:e2e:openclaw:gateway` - gateway mode (mock gateway running) + ## Spec Files ### Security & Auth diff --git a/tests/fixtures/openclaw/agents/engineering-bot/sessions/sessions.json b/tests/fixtures/openclaw/agents/engineering-bot/sessions/sessions.json new file mode 100644 index 0000000..acb9f70 --- /dev/null +++ b/tests/fixtures/openclaw/agents/engineering-bot/sessions/sessions.json @@ -0,0 +1,15 @@ +{ + "agent:engineering-bot:main": { + "sessionId": "sess-eng-main", + "updatedAt": 4102444800000, + "chatType": "dm", + "lastChannel": "engineering", + "model": { + "primary": "openai/gpt-5" + }, + "totalTokens": 24500, + "inputTokens": 16100, + "outputTokens": 8400, + "contextTokens": 120000 + } +} diff --git a/tests/fixtures/openclaw/agents/research-bot/sessions/sessions.json b/tests/fixtures/openclaw/agents/research-bot/sessions/sessions.json new file mode 100644 index 0000000..29b0b7c --- /dev/null +++ b/tests/fixtures/openclaw/agents/research-bot/sessions/sessions.json @@ -0,0 +1,15 @@ +{ + "agent:research-bot:main": { + "sessionId": "sess-research-main", + "updatedAt": 4102444700000, + "chatType": "dm", + "lastChannel": "research", + "model": { + "primary": "anthropic/claude-sonnet-4-5" + }, + "totalTokens": 12800, + "inputTokens": 8100, + "outputTokens": 4700, + "contextTokens": 64000 + } +} diff --git a/tests/fixtures/openclaw/cron/jobs.json b/tests/fixtures/openclaw/cron/jobs.json new file mode 100644 index 0000000..be775ca --- /dev/null +++ b/tests/fixtures/openclaw/cron/jobs.json @@ -0,0 +1,59 @@ +{ + "version": 1, + "jobs": [ + { + "id": "job-eng-daily", + "agentId": "engineering-bot", + "name": "daily engineering standup", + "enabled": true, + "createdAtMs": 1762550000000, + "updatedAtMs": 1762550600000, + "schedule": { + "kind": "cron", + "expr": "0 9 * * 1-5", + "tz": "UTC" + }, + "payload": { + "kind": "message", + "message": "Post daily engineering summary", + "model": "openai/gpt-5" + }, + "delivery": { + "mode": "dm", + "channel": "engineering" + }, + "state": { + "nextRunAtMs": 1762602000000, + "lastRunAtMs": 1762515600000, + "lastStatus": "success" + } + }, + { + "id": "job-research-hourly", + "agentId": "research-bot", + "name": "hourly trend scan", + "enabled": true, + "createdAtMs": 1762551000000, + "updatedAtMs": 1762551000000, + "schedule": { + "kind": "cron", + "expr": "0 * * * *", + "tz": "UTC" + }, + "payload": { + "kind": "message", + "message": "Scan latest market/AI trends", + "model": "anthropic/claude-sonnet-4-5" + }, + "delivery": { + "mode": "dm", + "channel": "research" + }, + "state": { + "nextRunAtMs": 1762594800000, + "lastRunAtMs": 1762591200000, + "lastStatus": "success" + } + } + ] +} diff --git a/tests/fixtures/openclaw/openclaw.json b/tests/fixtures/openclaw/openclaw.json new file mode 100644 index 0000000..08545c8 --- /dev/null +++ b/tests/fixtures/openclaw/openclaw.json @@ -0,0 +1,49 @@ +{ + "version": 1, + "gateway": { + "host": "127.0.0.1", + "port": 18789 + }, + "agents": { + "defaults": { + "workspace": "workspaces" + }, + "list": [ + { + "id": "engineering-bot", + "name": "engineering-bot", + "default": true, + "workspace": "workspaces/engineering-bot", + "agentDir": "agents/engineering-bot", + "identity": { + "name": "Engineering Bot", + "theme": "software-engineer", + "emoji": ":gear:" + }, + "model": { + "primary": "openai/gpt-5" + }, + "tools": { + "allow": ["sessions_send", "sessions_history", "shell"] + } + }, + { + "id": "research-bot", + "name": "research-bot", + "workspace": "workspaces/research-bot", + "agentDir": "agents/research-bot", + "identity": { + "name": "Research Bot", + "theme": "analyst", + "emoji": ":book:" + }, + "model": { + "primary": "anthropic/claude-sonnet-4-5" + }, + "tools": { + "allow": ["web", "sessions_send"] + } + } + ] + } +} diff --git a/tests/fixtures/openclaw/workspaces/engineering-bot/TOOLS.md b/tests/fixtures/openclaw/workspaces/engineering-bot/TOOLS.md new file mode 100644 index 0000000..9c3d5ab --- /dev/null +++ b/tests/fixtures/openclaw/workspaces/engineering-bot/TOOLS.md @@ -0,0 +1,3 @@ +- `shell` +- `sessions_send` +- `sessions_history` diff --git a/tests/fixtures/openclaw/workspaces/engineering-bot/identity.md b/tests/fixtures/openclaw/workspaces/engineering-bot/identity.md new file mode 100644 index 0000000..a384832 --- /dev/null +++ b/tests/fixtures/openclaw/workspaces/engineering-bot/identity.md @@ -0,0 +1,3 @@ +# Engineering Bot +theme: software-engineer +emoji: ⚙️ diff --git a/tests/fixtures/openclaw/workspaces/engineering-bot/soul.md b/tests/fixtures/openclaw/workspaces/engineering-bot/soul.md new file mode 100644 index 0000000..0ea5aa2 --- /dev/null +++ b/tests/fixtures/openclaw/workspaces/engineering-bot/soul.md @@ -0,0 +1,2 @@ +# Engineering Bot +Focus on implementation quality, safe migrations, and test coverage. diff --git a/tests/fixtures/openclaw/workspaces/research-bot/TOOLS.md b/tests/fixtures/openclaw/workspaces/research-bot/TOOLS.md new file mode 100644 index 0000000..b233f88 --- /dev/null +++ b/tests/fixtures/openclaw/workspaces/research-bot/TOOLS.md @@ -0,0 +1,2 @@ +- `web` +- `sessions_send` diff --git a/tests/fixtures/openclaw/workspaces/research-bot/identity.md b/tests/fixtures/openclaw/workspaces/research-bot/identity.md new file mode 100644 index 0000000..d694831 --- /dev/null +++ b/tests/fixtures/openclaw/workspaces/research-bot/identity.md @@ -0,0 +1,3 @@ +# Research Bot +theme: analyst +emoji: 📚 diff --git a/tests/fixtures/openclaw/workspaces/research-bot/soul.md b/tests/fixtures/openclaw/workspaces/research-bot/soul.md new file mode 100644 index 0000000..63d5e1e --- /dev/null +++ b/tests/fixtures/openclaw/workspaces/research-bot/soul.md @@ -0,0 +1,2 @@ +# Research Bot +Focus on evidence, citations, and concise recommendations. diff --git a/tests/openclaw-harness.spec.ts b/tests/openclaw-harness.spec.ts new file mode 100644 index 0000000..3d1d9ea --- /dev/null +++ b/tests/openclaw-harness.spec.ts @@ -0,0 +1,56 @@ +import { test, expect } from '@playwright/test' +import { API_KEY_HEADER } from './helpers' + +const EXPECT_GATEWAY = process.env.E2E_GATEWAY_EXPECTED === '1' + +test.describe('OpenClaw Offline Harness', () => { + test('capabilities expose OpenClaw state dir/config in offline test mode', async ({ request }) => { + const res = await request.get('/api/status?action=capabilities', { + headers: API_KEY_HEADER, + }) + expect(res.status()).toBe(200) + + const body = await res.json() + expect(body.openclawHome).toBe(true) + expect(Boolean(body.claudeHome)).toBeTruthy() + expect(Boolean(body.gateway)).toBe(EXPECT_GATEWAY) + }) + + test('sessions API reads fixture sessions without OpenClaw install', async ({ request }) => { + const res = await request.get('/api/sessions', { + headers: API_KEY_HEADER, + }) + expect(res.status()).toBe(200) + + const body = await res.json() + expect(Array.isArray(body.sessions)).toBe(true) + expect(body.sessions.length).toBeGreaterThan(0) + expect(body.sessions[0]).toHaveProperty('agent') + expect(body.sessions[0]).toHaveProperty('tokens') + }) + + test('cron API reads fixture jobs', async ({ request }) => { + const res = await request.get('/api/cron?action=list', { + headers: API_KEY_HEADER, + }) + expect(res.status()).toBe(200) + + const body = await res.json() + expect(Array.isArray(body.jobs)).toBe(true) + expect(body.jobs.length).toBeGreaterThan(0) + expect(body.jobs[0]).toHaveProperty('name') + expect(body.jobs[0]).toHaveProperty('schedule') + }) + + test('gateway config API reads fixture config', async ({ request }) => { + const res = await request.get('/api/gateway-config', { + headers: API_KEY_HEADER, + }) + expect(res.status()).toBe(200) + + const body = await res.json() + expect(typeof body.path).toBe('string') + expect(body.path.endsWith('openclaw.json')).toBe(true) + expect(body.config).toHaveProperty('agents') + }) +})