cc-haha/src/server/index.ts
程序员阿江(Relakkes) 071975e0cc Improve desktop failure diagnosis with exportable logs
Desktop failures were previously hard to debug from issue reports because server-side and CLI startup details were only partially visible in the UI. This adds a dedicated cc-haha diagnostics store with sanitized structured events, runtime error summaries, a Settings diagnostics view, and an exportable bundle that users can attach to reports.

Constraint: Diagnostic exports must not include chat content, file contents, full environment variables, API keys, bearer tokens, cookies, or OAuth tokens.
Rejected: Export raw server logs | easier to debug but too likely to leak secrets and private workspace data.
Rejected: Keep diagnostics only in transient UI errors | still leaves maintainers unable to diagnose later GitHub issues.
Confidence: high
Scope-risk: moderate
Directive: Do not add raw transcript, prompt, attachment, or environment dumps to diagnostics without a separate privacy review.
Tested: ANTHROPIC_API_KEY=test-key bun test src/server/__tests__/diagnostics-service.test.ts
Tested: ANTHROPIC_API_KEY=test-key bun test src/server/__tests__/conversation-service.test.ts
Tested: ANTHROPIC_API_KEY=test-key bun test src/server/__tests__/conversations.test.ts
Tested: cd desktop && bun run test src/__tests__/diagnosticsSettings.test.tsx --run
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build
Tested: agent-browser E2E on local server/dev UI for CLI startup failure, provider test failure, diagnostics tab, copy summary, export bundle, and tar/secret scan
Not-tested: Destructive clear-logs button in browser E2E; local deletion was intentionally not clicked.
2026-05-02 12:41:31 +08:00

283 lines
9.3 KiB
TypeScript

/**
* Claude Code Desktop App — HTTP + WebSocket Server
*
* 为桌面端 UI 提供 REST API 和 WebSocket 实时通信。
* 读写与 CLI 完全相同的文件系统,确保 CLI/UI 数据互通。
*/
import { handleApiRequest } from './router.js'
import { handleWebSocket, type WebSocketData } from './ws/handler.js'
import { corsHeaders } from './middleware/cors.js'
import { requireAuth } from './middleware/auth.js'
import { teamWatcher } from './services/teamWatcher.js'
import { cronScheduler } from './services/cronScheduler.js'
import { handleProxyRequest } from './proxy/handler.js'
import { ProviderService } from './services/providerService.js'
import { handleHahaOAuthCallback } from './api/haha-oauth.js'
import { ensureDesktopCliLauncherInstalled } from './services/desktopCliLauncherService.js'
import { enableConfigs } from '../utils/config.js'
import { diagnosticsService } from './services/diagnosticsService.js'
function readArgValue(flag: string): string | undefined {
const args = process.argv.slice(2)
const index = args.indexOf(flag)
if (index === -1) return undefined
return args[index + 1]
}
function hasArgFlag(flag: string): boolean {
return process.argv.slice(2).includes(flag)
}
function resolveServerOptions() {
const portArg = readArgValue('--port')
const port = Number.parseInt(portArg || process.env.SERVER_PORT || '3456', 10)
const host = readArgValue('--host') || process.env.SERVER_HOST || '127.0.0.1'
const cliPath = readArgValue('--cli-path')
const authRequired = hasArgFlag('--auth-required')
if (cliPath) {
process.env.CLAUDE_CLI_PATH = cliPath
}
return { port, host, authRequired }
}
const SERVER_OPTIONS = resolveServerOptions()
const PORT = SERVER_OPTIONS.port
const HOST = SERVER_OPTIONS.host
export function startServer(port = PORT, host = HOST) {
enableConfigs()
diagnosticsService.installConsoleCapture()
ProviderService.setServerPort(port)
const localConnectHost =
host === '0.0.0.0' || host === '127.0.0.1' || host === 'localhost'
? '127.0.0.1'
: host
/**
* Auth is required when explicitly opted in or when bound to a non-localhost address.
* - Default localhost dev: no auth needed (tests pass as-is).
* - Production / non-localhost (e.g. 0.0.0.0): auth enforced automatically.
* - Explicit opt-in: SERVER_AUTH_REQUIRED=1 forces auth even on localhost.
*/
const authRequired =
SERVER_OPTIONS.authRequired ||
process.env.SERVER_AUTH_REQUIRED === '1' ||
host !== '127.0.0.1'
const server = Bun.serve<WebSocketData>({
port,
hostname: host,
idleTimeout: 60,
async fetch(req, server) {
const url = new URL(req.url)
const origin = req.headers.get('Origin')
// Handle CORS preflight
if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders(origin) })
}
// WebSocket upgrade
if (url.pathname.startsWith('/ws/')) {
// Enforce authentication when required
if (authRequired) {
const authError = requireAuth(req)
if (authError) {
const headers = new Headers(authError.headers)
for (const [key, value] of Object.entries(corsHeaders(origin))) {
headers.set(key, value)
}
return new Response(authError.body, { status: authError.status, headers })
}
}
// Validate session ID format
const sessionId = url.pathname.split('/').pop() || ''
if (!sessionId || !/^[0-9a-zA-Z_-]{1,64}$/.test(sessionId)) {
return new Response('Invalid session ID', { status: 400 })
}
const upgraded = server.upgrade(req, {
data: {
sessionId,
connectedAt: Date.now(),
channel: 'client',
sdkToken: null,
serverPort: port,
serverHost: localConnectHost,
},
})
if (upgraded) return undefined
return new Response('WebSocket upgrade failed', { status: 400 })
}
// Internal SDK WebSocket used by the spawned Claude CLI.
if (url.pathname.startsWith('/sdk/')) {
const sessionId = url.pathname.split('/').pop() || ''
if (!sessionId || !/^[0-9a-zA-Z_-]{1,64}$/.test(sessionId)) {
return new Response('Invalid session ID', { status: 400 })
}
const upgraded = server.upgrade(req, {
data: {
sessionId,
connectedAt: Date.now(),
channel: 'sdk',
sdkToken: url.searchParams.get('token'),
serverPort: port,
serverHost: localConnectHost,
},
})
if (upgraded) return undefined
return new Response('WebSocket upgrade failed', { status: 400 })
}
if (url.pathname === '/callback') {
return handleHahaOAuthCallback(url)
}
// REST API
if (url.pathname.startsWith('/api/')) {
// Enforce authentication when required
if (authRequired) {
const authError = requireAuth(req)
if (authError) {
const headers = new Headers(authError.headers)
for (const [key, value] of Object.entries(corsHeaders(origin))) {
headers.set(key, value)
}
return new Response(authError.body, { status: authError.status, headers })
}
}
try {
const response = await handleApiRequest(req, url)
// Add CORS headers to all responses
const headers = new Headers(response.headers)
for (const [key, value] of Object.entries(corsHeaders(origin))) {
headers.set(key, value)
}
return new Response(response.body, {
status: response.status,
headers,
})
} catch (error) {
void diagnosticsService.recordEvent({
type: 'api_request_failed',
severity: 'error',
summary: error instanceof Error ? error.message : String(error),
details: { path: url.pathname, method: req.method, error },
})
console.error('[Server] API error:', error)
return Response.json(
{ error: 'Internal server error' },
{ status: 500, headers: corsHeaders() }
)
}
}
// Proxy — protocol-translating reverse proxy for OpenAI-compatible APIs
if (url.pathname.startsWith('/proxy/')) {
if (authRequired) {
const authError = requireAuth(req)
if (authError) {
const headers = new Headers(authError.headers)
for (const [key, value] of Object.entries(corsHeaders(origin))) {
headers.set(key, value)
}
return new Response(authError.body, { status: authError.status, headers })
}
}
try {
const response = await handleProxyRequest(req, url)
const headers = new Headers(response.headers)
for (const [key, value] of Object.entries(corsHeaders(origin))) {
headers.set(key, value)
}
return new Response(response.body, {
status: response.status,
headers,
})
} catch (error) {
void diagnosticsService.recordEvent({
type: 'proxy_request_failed',
severity: 'error',
summary: error instanceof Error ? error.message : String(error),
details: { path: url.pathname, method: req.method, error },
})
console.error('[Server] Proxy error:', error)
return Response.json(
{ type: 'error', error: { type: 'api_error', message: 'Internal proxy error' } },
{ status: 500, headers: corsHeaders() },
)
}
}
// Health check
if (url.pathname === '/health') {
return Response.json(
{ status: 'ok', timestamp: new Date().toISOString() },
{ headers: corsHeaders(origin) },
)
}
return new Response('Not Found', { status: 404 })
},
websocket: handleWebSocket,
})
// Start watching ~/.claude/teams/ for real-time WebSocket push
teamWatcher.start()
// Start the cron scheduler to execute scheduled tasks
cronScheduler.start()
void ensureDesktopCliLauncherInstalled().catch((error) => {
console.error(
'[desktop-cli-launcher] failed to install bundled launcher:',
error instanceof Error ? error.message : error,
)
})
console.log(`[Server] Claude Code API server running at http://${host}:${port}`)
return server
}
// ─── Graceful shutdown: kill all CLI subprocesses on exit ────────────────────
import { conversationService } from './services/conversationService.js'
function cleanupAllSessions() {
const active = conversationService.getActiveSessions()
if (active.length > 0) {
console.log(`[Server] Shutting down — killing ${active.length} CLI subprocess(es)`)
for (const sessionId of active) {
conversationService.stopSession(sessionId)
}
}
}
process.on('SIGTERM', () => {
console.log('[Server] Received SIGTERM')
cleanupAllSessions()
process.exit(0)
})
process.on('SIGINT', () => {
console.log('[Server] Received SIGINT')
cleanupAllSessions()
process.exit(0)
})
process.on('exit', () => {
cleanupAllSessions()
})
// Direct execution
if (import.meta.main) {
startServer()
}